UE で外部からの物理の影響を (それなりに) 受けるキャラクターを作る

第22回UE5ぷちコン|株式会社ヒストリア で以下の作品を提出しました。

www.youtube.com

このゲームはシンプルなウォータースライダーですが、キャラクターがジャンプ台や噴水といった外部からくる物理の影響を受けるようになっています。
それに加えて、(ジャンプを除けば)現実でのウォータースライダーと同様にプレイヤーはキャラクターに対して身体の左右の角度を (Yaw 軸) 緩やかに変えることしかできません。
当然、ジャンプ中も方向転換等はできません。

とても操作に不自由があるゲームですし、物理に頼っているうえでの理不尽な挙動もあるのですが、なんとかゲームっぽくまとめることができたのではないかと思います。

キャラクターが物理の影響を受ける

今回のゲームにおいて、「キャラクターが物理の影響を受ける」には2つの観点があります。

  1. キャラクターが「外部からの力」を受けて、位置や回転が変わる
  2. キャラクターのメッシュが「外部からの力」を受けて、位置や回転が変わる

ここでの「外部からの力」は AddForceAddImpulse のようなものや、他の Simulate Physics なオブジェクトとの衝突、そして重力もそうですね。

粗いですが以下の画像でいえば、ジャンプ台になっている雲からの力を受けて「キャラクター」が物理的に上空に跳ね返され、それと同時に「キャラクターのメッシュ」も加速度や重力の影響を受けて手足がバタバタします。

以降ではそれぞれについてみていきます。

キャラクターが「外部からの力」を受ける

ここに関しては Mover plugin に 5.4 から試験的に実装された「Physics Based Character Movement」を使っているだけです。

Unreal Engine 5.4 Release Notes | Unreal Engine 5.5 Documentation | Epic Developer Community

Physics Based Character movement is a new Experimental feature we are building into the Mover plugin which provides for two-way physics-based interaction between the character and its environment.
- The system uses movement modes created in Mover and takes the output movement as a target motion for a physics representation of the character.
- The character imparts forces on the parts of the physics world it is interacting with, and physics forces and constraints can in turn affect the movement of the character.
- Multiplayer physics-based motion with rollback and re-simulation is supported using Networked Physics.

Mover plugin について把握したい方は以下の記事がとても参考になります。(私もこちらで知りました)

UE5 Character Mover 2.0とは - Let's Enjoy Unreal Engine

Quick Start

当然ながら UE 5.4 以上が必要です。

Plugin の有効化

まずは Mover と Mover Examples の plugin を有効化します。

Mover Examples が Engine/Plugins のフォルダにあるのが確認できます。
まずは、Maps フォルダにある L_PhysicsSimulatedCharacter を開いて遊んでみるのもいいかもしれません。

Example から PhysicsMannyPawn をコピー

続いて Pawns フォルダに PhysicsMannyPawn があるので、こちらを自身の Content の方に copy してきましょう。
名前がかぶると面倒なので、私は BP_PhysicsMannyPawn に変更しました。

以下のように BP_ThirdPersonCharacter と並べてみます。

まずここで面白いのが、先程の alwei さんの記事でも触れられていましたが、PhysicsMannyPawn のキャラクターは、Simulate Physics されたオブジェクトと同じように押すことができます。

以下の画像は BP_ThirdPersonCharacter で押していますが、もちろん PhysicsMannyPawn 同士で押し合うこともできます。

どうしてこうなるのか?

Mover のコードを追えていないので詳しいことはわかりませんが、まず PhysicsMannyPawnCapsuleSimulate Physics が有効化されています。

ちなみに BP_ThirdPersonCharacter の方でも CapsuleSimulate Physics を有効にしたとしても、以下のコードがあるためそもそも操作すらできなくなります。

// CharacterMovementComponent.cpp

void UCharacterMovementComponent::TickComponent(
{
    // 略
      
    // See if we fell out of the world.
    const bool bIsSimulatingPhysics = UpdatedComponent->IsSimulatingPhysics();
   
  // We don't update if simulating physics (eg ragdolls).
    if (bIsSimulatingPhysics)
    {

詳細も気になるところですが、まずは「これで物理に反応できるキャラクターが手に入った」ということです。

以下は、Physics Constraint を使ったバネを使ったデモです。
ThirdPersonCharacter の方は、ジャンプして着地してもそのままバネの上に立つだけですが、PhysicsMannyPawn の方はバネの跳ね返りの力でそのまま跳ね上がっています。キーを推して能動的にジャンプはしていません。

ThirdPersonCharacter
PhysicsMannyPawn

このバネは以下のチュートリアルで作成したものです。この人のチュートリアルは物理挙動好きな私にはどれも楽しいです😇
UE4 Jump Pad - using Physics Constraints in Unreal Engine 4 Tutorial How To - YouTube

キャラクターのメッシュが「外部からの力」を受ける

上記で貼った Gif 動画で分かる通り、現状ではキャラクターは跳ね上がった際も設定されたジャンプ (fall loop) アニメーションが再生されています。これを物理の影響を受けているようなポーズにしたいです。

Physical Animation

こちらも Physical Animation を使っているだけです。

Quick Start

Physical Animation Component の追加

ここまでで作成してきた Mover で動く BP_PhysicsMannyPawnPhysical Animation Component を追加します。

Physics Asset の付いた Skeletal Mesh に入れ替える

続いて、Skeletal Mesh の入れ替えを行います。というのも、ここまで使ってきた BP_PhysicsMannyPawn がデフォルトで利用する Mover Example の Skeletal Mesh は Physics Asset が適用されていません。

よって、Physics Asset が付いたものに入れ替えます。(Mover Example の Skeletal Mesh をコピーして、Physics Asset をつけても構いません)
私は ThirdPersonCharacter が利用している SKM_Manny に入れ替えました。

Skeletal Mesh の Collision を調整

上述のドキュメント通りで大丈夫です。

ドキュメントにもありますが、Capsule の Object Type が Pawn であるため、最低限 Pawn は除外する必要があります。

BP に設定を書く

ドキュメントとの大きな違いは Profile を使わず、Physical Animation Data を直接 Make して使っています。
また、基準となる Bone を pelvis にしています。

Physics Asset を見ると分かりますが、pelvis 以下ということは実質全てです。

また、Set All Bodies Below Simulate PhysicsInclude Self は無効にしています。これを有効にしてしまうと pelvis もぐにゃぐにゃになって動けなくなります。

Physics Blend Weight のみ変数化していますが、今回は 0.7 に設定しています。これを 1.0 に近づけるほどラグドールっぽくなります。

後はこれを呼び出すだけです。今回は Begin Play で呼び出してしまいます。

さて、これでラグドールっぽいけど動くキャラクターが手に入りました。

最終確認

ここまででウォータースライダーゲームのジャンプ台の動きが再現できました。(ウォータースライダーはキャラクターのポーズが寝そべっていまので姿勢は違いますが)

ちなみに、ウォータースライダーゲームの能動的なジャンプは、Physical Animation を無効化しています。

終わりに

Physical Animation はダメージリアクション等で使いやすい機能ですが、キャラクターを動かす時に使うと違和感のある動きになってしまいます。

今回はウォータースライダーにいるキャラクターでほぼ動きが無いこともあり、違和感なく利用することができました。

加えて、Mover の Physics Based Character Movement を使うことで、キャラクターがより簡単に物理の影響を受けることができるようになり、 Physical Animation が生かされるようになりました。

余談ですが、最初は Physics Control を使って実現しようとしていたのですが、なかなかうまくいかず断念しました。次は Physics Control も使ってなにかできればと思っています。

Roblox でのデータ永続化を学ぶ

Roblox でゲームを作るにあたり、適当にチュートリアルをこなした後に「まずはデータの永続化だろう」ということでそこから押さえていくことにします。

基本

公式の中級チュートリアルにある以下のドキュメントが基本になると思います。

create.roblox.com

ほぼ同じ内容になるかもしれませんが一つ一つ見ていきたいと思います。

Enabling Studio Access

デフォルトでは Studio から data stores にはアクセスできないので有効化します。

Creating a Data Store

Data stores は DataStoreService で管理されるようです。

DataStoreService を確認するにあたり、 Data stores のページが分かりやすいので見ていきます。

Data Stores

DataStoreService lets you store data that needs to persist between sessions, such as items in a player's inventory or skill points.
Data stores are consistent per experience, so any place in an experience can access and change the same data, including places on different servers.

per experience と書かれている通りで、この永続化は experience 単位での保存であり、 experience 内の複数の place からアクセス可能のようです。
experience は以下の最初のチュートリアルにあるように一つのバーチャル空間(ゲーム)と考えておけばいいと思います。
Creating Your First Experience | 文書 - Roblox クリエーターハブ

適切ではないかもしれません、UE で言い換えれば、一つの UE のプロジェクトが experience、プロジェクト内のレベルが各 place に相当します。

Enabling Studio Access

先ほど有効化したように、ゲーム設定のセキュリティから有効化しました

Accessing a Data Store

導入は以下のように GetService で行い、名前で個別の Data store にアクセスできます。

local DataStoreService = game:GetService("DataStoreService")
local experienceStore = DataStoreService:GetDataStore("PlayerExperience")`

The server can only access data stores through Scripts. Attempting client-side access in a LocalScript causes an error.

NOTE にある通り、サーバーからのみアクセスできます

Scopes

以下のような形式で scope を付与でき、GetDataStore の第二引数で絞り込みができます

ただ、NOTE にある通り、この方法は現在は Listing and Prefixes の方法に置き換えることが推奨されています。
ただ、既存のコードで見かけることがあると思うので知識としておさえておくと良さそうです。

For new experiences, it is not recommended to use the legacy scopes feature. Instead, use listing and prefixes to organize keys in your data store. If you have an existing experience that uses scopes, you can continue using them.

Managing a Data Store

Setting Data

A data store is essentially a dictionary, similar to a Lua table. A unique key indexes each value in the data store, such as a player's unique Player.UserId or a named string for a game promo.

NOTE にある通りで、data store は辞書データであり、プレイヤーの id やゲーム内で使う文字列をキーとして使います。

local success, errorMessage = pcall(function()
    experienceStore:SetAsync("User_1234", 50)
end)
if not success then
    print(errorMessage)
end

SetAsync に key / value を与えることで簡単に保存ができます。
以下の NOTE にある通り、SetAsync はネットワーク呼び出しであるので失敗の可能性があり、よって pcall を使ってエラーハンドリングを行います。

Functions like SetAsync() that access a data store's contents are network calls that may occasionally fail. As shown above, it's recommended that these calls be wrapped in pcall() to catch and handle errors.

Reading Data
local success, currentExperience = pcall(function()
    return experienceStore:GetAsync("User_1234")
end)
if success then
    print(currentExperience)
end

GetAsync に key を与えることで value が簡単に取得できます。

The values you retrieve using GetAsync() sometimes can be out of sync with the backend due to the caching behavior. To opt out of caching, see Disabling caching.

上記の NOTE にある通りで、 cache の仕組みで同期された値を取得できない場合があり、場合によっては cache 自体を無効化することもできます。
cache に関しては後ほど詳細が出てきます。

pcall でエラーハンドリングすることに関しては SetAsync と同じようです。

Incrementing Data
local success, newExperience = pcall(function()
    return experienceStore:IncrementAsync("Player_1234", 1)
end)
if success then
    print(newExperience)
end

数値データに関しては、キーに対して変更値を与えることで直接値を更新できるようです。

Updating Data
local function makeNameUpper(currentName)
    local nameUpper = string.upper(currentName)
    return nameUpper
end

local success, updatedName = pcall(function()
    return nicknameStore:UpdateAsync("User_1234", makeNameUpper)
end)
if success then
    print("Uppercase Name:", updatedName)
end

UpdateAsync はデータの更新ですが、SetAsync と違い、key に対して新しい value を渡すのではなく、現在値を更新する callback function を渡します。
通常は更新後の値を return しますが、nil を返すと更新処理がキャンセルされます。

The callback function passed into UpdateAsync() is not permitted to yield, so it cannot contain any yielding function like task.wait().

また、上記の NOTE にある通りで、 task.wait() を含むような yield 処理が入る関数にすることはできません。

Set vs. Update

この辺は一般的なデータ保存であることですが、Set の場合は、GetAsync で取得したデータが必ずしも最新のデータかは分からず(他のサーバーで更新がされるなど)SetAsync するときには意図せず直前の更新を無視した更新になってしまう可能性があります。

一方で Update の場合は、更新時に直前のデータを読み出すのでより安全にデータの更新ができます。

まあ、この辺はデータの性質(例えば確実に一人のユーザーからしか更新されない or いろんなユーザー・サーバーから更新される)を考慮して選択すればいいでしょう。

Removing Data
local success, removedValue = pcall(function()
    return nicknameStore:RemoveAsync("User_1234")
end)
if success then
    print(removedValue)
end

削除は key を指定して行います。

Ordered Data Stores

Data store は通常ソートされていませんが、一部の用途でソートされたデータ必要になるため、OrderedDataStore が用意されています。

用意する方法は以下のように取得方法を変えるだけです。

-- 通常の Data store
local characterAgeStore = DataStoreService:GetDataStore("CharacterAges")
-- Ordered Data Store
local characterAgeStore = DataStoreService:GetOrderedDataStore("CharacterAges")

これだけで取得される型が OrderedDataStore に変更されています。

OrderedDataStore | 文書 - Roblox クリエーターハブ

OrderedDataStore の仕様は上記にあります。

A OrderedDataStore is essentially a GlobalDataStore with the exception that stored values must be positive integers.
It exposes a method GetSortedAsync() which allows inspection of the entries in sorted order using a DataStorePages object.

ドキュメントを見ると、まず Data store は GlobalDataStore であり、それに対して **positive integersのみを value にとるという制限がOrderedDataStoreにはあるようです。 また、GetSortedAsyncというメソッドが用意されており、DataStorePages` というオブジェクトを使ってソートされた結果を表現するようです。

Ordered data stores do not support versioning and metadata, so DataStoreKeyInfo is always nil for keys in an OrderedDataStore. If you need versioning and metadata support, use a DataStore.

また、versioning や metadata もサポートされず、 DataStoreKeyInfonil になるようです。

Ordered data stores do not support the optional userIds parameter for SetAsync() or IncrementAsync().

加えて、SetAsync, IncrementAsync で利用できる userIds パラメータも利用できないとあります。

認識していませんでしたが、これらのメソッドには userIds というパラメータが設定でき、おそらくその処理を実行したユーザーの記録に使われるのだと思います。

OrderedDataStore | 文書 - Roblox クリエーターハブ

こう見ると、GetDataStore 時にスコープを指定できたりと細かい設定はありそうですね。

何でソートされるか?

value に integers しか設定できないという仕様からも、おそらく value でのソートが行われるのだと思います。 leaderboard が用途にあげられているので、まあ名前でソートしても意味がないので当然といえば当然かもしれません。

Metadata

まず、Service-defined, User-defined の 2 種類があり前者は DB にもありそうな update time や creation time です。
後者はユーザーが指定できるもので、ちょうど先程 SetAsync の引数としても出てきていた DataStoreSetOptions を通して設定するようです。

SetAsync の場合
local experienceStore = DataStoreService:GetDataStore("PlayerExperience")

local setOptions = Instance.new("DataStoreSetOptions")
setOptions:SetMetadata({["ExperienceElement"] = "Fire"})

local success, errorMessage = pcall(function()
    experienceStore:SetAsync("User_1234", 50, {1234}, setOptions)
end)
if not success then
    print(e

そのままではありますが、DataStoreSetOptionsインスタンスを作成し、指定したいメタデータを用意したら、それを SetAsync の第四引数に設定します。

ここでは先程紹介した userIds も第三引数に指定されていることが分かります。

Table of UserIds, highly recommended to assist with content copyright and intellectual property tracking/removal.

となっているので、 userIds は基本的には設定した方がいいんでしょうか。

GetAsync(), IncrementAsync(), and RemoveAsync() の場合
local experienceStore = DataStoreService:GetDataStore("PlayerExperience")

local success, currentExperience, keyInfo = pcall(function()
    return experienceStore:GetAsync("User_1234")
end)
if success then
    print(currentExperience)
    print(keyInfo.Version)
    print(keyInfo.CreatedTime)
    print(keyInfo.UpdatedTime)
    print(keyInfo:GetUserIds())
    print(keyInfo:GetMetadata())
end

これまでは 実行結果としては単一の結果 (success は pcall の結果であり除外) しか受け取っていなかったのに対して、 keyInfo を追加で受け取ることができます。

GetAsync の仕様を確認すると、戻り値は Tuple になっており、今回の DataStoreKeyInfo が取得できることが分かります。

DataStoreKeyInfo に metadata だけでなく userIds や version の情報も格納されていることが分かります。

UpdateAsync の場合
local nicknameStore = DataStoreService:GetDataStore("Nicknames")

local function makeNameUpper(currentName, keyInfo)
    local nameUpper = string.upper(currentName)
    local userIDs = keyInfo:GetUserIds()
    local metadata = keyInfo:GetMetadata()
    return nameUpper, userIDs, metadata
end

local success, updatedName, keyInfo = pcall(function()
    return nicknameStore:UpdateAsync("User_1234", makeNameUpper)
end)
if success then
    print(updatedName)
    print(keyInfo.Version)
    print(keyInfo.CreatedTime)
    print(keyInfo.UpdatedTime)
    print(keyInfo:GetUserIds())
    print(keyInfo:GetMetadata())
end

特筆すべき点はありませんが、同様に戻り値に keyInfo を取得できています。

その他

まず NOTE として

You must always update metadata definitions with a value, even if there are no changes to the current value, otherwise, you lose the current value. This applies to SetAsync(), IncrementAsync(), and UpdateAsync().

とあります。つまり、metadata に変更がなくても常に更新しないと現在値が失われるということです。まあ、wrapper 関数を用意して常に値をいれるようにすべきですね。

また、ユーザー定義のメタデータには以下の制限があります。

  • Key length: up to 50 characters.
  • Value length: up to 250 characters.
  • No limit for the total number of key-value pairs but the total size cannot exceed 300 characters.

Versioning

更新系のメソッドを利用した際に priodically にバージョン付けられたバックアップを作成してくれるようです。
UTC hour 単位となっており、その単位時間内で最初の書き込みにはバックアップが作成され、その時間内の2度目以降の書き込みは新しいバックアップではなく上書きになるようです。
まあ、一定時間ごとの最終データが記録されていると考えておけば良さそうです。

また、保持期限は 30 日で最新データに関してだけは無期限に保持されるとのことです。

Snapshots

Snapshot Data Stores - Data Stores | 文書 - Roblox クリエーターハブ

上記の API を通して、experience 内の全データのスナップショットを取得できます。これは、前述の Versioning のデータと同様なのですが、versioning が上書きになる単位 UTC 時間内であっても、新しくバックアップが作成されます。

experience に大きな更新をかけるときなどに、データが壊れて戻せなくなることを防ぐために直前に snapshots を取得するのが良さそうです。

Listing and Prefixes

ListDataStoresAsync(), ListKeysAsync() を使って、それぞれ Data store 、data store 内のキーの一覧を取得できます。

AllScopes Property

GetDataStore の部分で scope に関する言及がありました。そこで、scope で絞り込むよりも prefix で一覧することが推奨されていました。

ドキュメントだけだとパッと理解できなかったのでコードを書いて確認します。

local DataStoreService = game:GetService("DataStoreService")

local options = Instance.new("DataStoreOptions")
options.AllScopes = true

local dataStoreName = "DataStoreSample"

-- normal
local dataStore = DataStoreService:GetDataStore(dataStoreName)
-- scoped
local dataStoreScopedHouse = DataStoreService:GetDataStore(dataStoreName, "house")
-- all scopes
local dataStoreAllScopes = DataStoreService:GetDataStore(dataStoreName, "", options)

local DataStoreSample = {}

function DataStoreSample.GetData(key)
    local success, data = pcall(function()
        return dataStore:GetAsync(key)
    end)
    
    if success then
        return data
    end
end

function DataStoreSample.SetData(key, data)
    local success, data = pcall(function()
        return dataStore:SetAsync(key, data)
    end)

    if success then
        print("success")
    end
end

function DataStoreSample.GetDataFromHouse(key)
    local success, data = pcall(function()
        return dataStoreScopedHouse:GetAsync(key)
    end)
    
    if success then
        return data
    end
end

function DataStoreSample.SetDataToHouse(key, data)
    local success, data = pcall(function()
        return dataStoreScopedHouse:SetAsync(key, data)
    end)

    if success then
        print("success")
    end
end

function DataStoreSample.GetDataFromAll(key, scope)
    local success, data = pcall(function()
        return dataStoreAllScopes:GetAsync(`{scope}/{key}`)
    end)
    
    if success then
        return data
    end
end

function DataStoreSample.SetDataToAll(key, data)
    local success, data = pcall(function()
        return dataStoreAllScopes:SetAsync(key, data)
    end)

    if success then
        print("success")
    end
end

function DataStoreSample.ListData()
    local listSuccess, pages = pcall(function()
        return dataStoreAllScopes:ListKeysAsync()
    end)

    local results = {}
    if listSuccess then
        local items = pages:GetCurrentPage()
        if #items > 0 then
            for i, item in ipairs(items) do
                table.insert(results, item.KeyName) -- https://create.roblox.com/docs/ja-jp/reference/engine/classes/DataStoreKey
            end
        end
    end
    return results
end

function DataStoreSample.ListDataWithPrefix(prefix)
    local listSuccess, pages = pcall(function()
        return dataStoreAllScopes:ListKeysAsync(prefix)
    end)

    local results = {}
    if listSuccess then
        local items = pages:GetCurrentPage()
        if #items > 0 then
            for i, item in ipairs(items) do
                table.insert(results, item.KeyName)
            end
        end
    end
    return results
end

return DataStoreSample

名前は同じで、3 つの Data store を取得しています。

local options = Instance.new("DataStoreOptions")
options.AllScopes = true

local dataStoreName = "DataStoreSample"

-- normal
local dataStore = DataStoreService:GetDataStore(dataStoreName)
-- scoped
local dataStoreScopedHouse = DataStoreService:GetDataStore(dataStoreName, "house")
-- all scopes
local dataStoreAllScopes = DataStoreService:GetDataStore(dataStoreName, "", options)

When you use this property, the second parameter of GetDataStore() must be an empty string ("").

とあるように、AllScopes の場合は必ず第二引数は "" にします。

これを使って SetAsync, GetAsync そして ListKeysAsync を使うメソッドを定義しています。

これを使う側は以下の ServerScript です。

local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local DataStoreSample = require(ServerScriptService.DataStoreSample)

Players.PlayerAdded:Connect(function(player)
    local userId = "dummy_user_id"

    DataStoreSample.SetData(userId, "Test in Global") -- key is "global/dummy_user_id"
    DataStoreSample.SetDataToHouse(userId, "Test in House") -- key is "house/dummy_user_id"
    DataStoreSample.SetData(`house/{userId}`, "Test in Global 2") -- key is "global/house/dummy_user_id"
    DataStoreSample.SetDataToAll(`house/{userId}_2`, "Test in House 2") -- key is "house/dummy_user_id_2"

    local data = DataStoreSample.GetData(userId)
    local dataInHouse = DataStoreSample.GetDataFromHouse(userId)
    local dataInGlobalFromAll = DataStoreSample.GetDataFromAll(userId, "global")
    local dataInHouseFromAll = DataStoreSample.GetDataFromAll(userId, "house")

    -- それぞれの先頭のデータが取得できている
    print(`data is "{data}"`) --Output is "Test in Global"
    print(`dataInHouse is "{dataInHouse}"`) --Output is "Test in House"
    print(`dataInGlobalFromAll is "{dataInGlobalFromAll}"`) --Output is "Test in Global"
    print(`dataInHouseFromAll is "{dataInHouseFromAll}"`) --Output is "Test in House"

    print(DataStoreSample.ListData())
    -- {
    --    [1] = "global/house/dummy_user_id",
    --    [2] = "house/dummy_user_id",
    --    [3] = "house/dummy_user_id_2",
    --    [4] = "global/dummy_user_id"
    --  }
    print(DataStoreSample.ListDataWithPrefix("hou"))
    -- {
    --    [1] = "house/dummy_user_id",
    --    [2] = "house/dummy_user_id_2"
    -- }
    print(DataStoreSample.ListDataWithPrefix("glo"))
    -- {
    --    [1] = "global/house/dummy_user_id",
    --    [2] = "global/dummy_user_id"
    -- }
end)

確認できた通りで、AllScopes の場合 は SetAsync は scope を直接 house/ のように prefix を付けるだけで指定できます。
そして、同様に GetAsync も prefix を付けることで取り出すことができます。

また、ListKeysAsync に関しても期待通り prefix で絞り込むことができています。

ちなみに、AllScopes の場合は GetAsync の引数は必ず scope_name/key_name の形式で渡さないとエラーになります。

GetAsync - GlobalDataStore | 文書 - Roblox クリエーターハブ

Error Codes

これまでも pcall を使ってアクセスすることが言及されていますが、細かくエラーコードが網羅されていてこれは素晴らしいです。

Limits

Server Limits, Data Limits, Throughput Limits と制限が設けられています。

これらは experience 単位の Data store になります。制限に到達した場合は、4 つのキューに振り分けられるようです。
それは、set, ordered set, get, ordered get の 4 つになります。

案外 Data Limits にある文字列の長さ制限は気をつけなければいけないかもしれません。50 文字ですね。

Caching

データのキャッシュ機能がついています。GetAsync は 4 秒のキャッシュ時間があるようです。Data store はそもそもサーバーでしか実行されないと思いますが、サーバーからさらに backend に対してのリクエストのキャッシュということでしょうか。

キャッシュは更新系のメソッドで適用されるようですが、GetAsync でも取得時にキャッシュにデータがなければ作成されるようです。

また、GetVersionAsync, ListVersionsAsync, ListKeysAsync, ListDataStoresAsync は常に最新のデータを取得するようです。

Disabling Caching

GetAsyncDataStoreGetOptionsUseCache を無効化することでキャッシュを使わずにデータを取得できます。

が、制限もあるので用法用量を守ってということですね。

Best Practices

いくつかポイントがあるようですが、

  • Data store はできるだけ少ない数にし、Data store 間に関連を持たせる
    • DB のテーブルと似ていると言及されていますが、どれぐらいの数が適切かまでは書かれていません
  • 保存できるオブジェクトサイズは 4MB までであり、関連データは一つのオブジェクトとして保存すると良さそうです
    • 関連データに分けすぎると、フェッチの回数が多くなってしまうのでそれを避けられます
  • key の prefix を使ってデータを organize する

この辺を見ていると、状況は違いますが Firestore の設計に多少似ている部分もありそうです。

いったんまとめ

さて、データーストアの「概要」ページをなぞっていったわけですが、結構なボリュームでした。

ただ、おかげでだいたいのことは把握できましたし、簡単なサンプルコードも試せました。

自分には比較的馴染みがある Firestore の設計も参考にできそうなことも分かって良かったです。

「UEFN(Unreal Editor For Fortnite)でゲームづくりを始めよう!」で UEFN に入門した

wgn-obs.shop-pro.jp

読んだ書籍としては上記ですが、前半の内容はゲームメーカーズの方でも同じような内容のものが見れそうです。(書籍で利用するアセットもこのサイトから取得します)

gamemakers.jp

動機

前回のUE5ぷちコンで作ったゲームをどれぐらい UEFN に移植できるのかが興味があって、触ってみることにしました。
ずっと積まれていた本を消化したかったのもあります 😅

移植しようとしたゲームは以下のようなウォータースライダーのゲームです。

youtu.be

書籍を読んでみて

アセットのパスが変わっているものがある

書籍で利用する(サイトの方も同じですが)UEFN のアセットは「All > Fortnite > Devices」にあるとなっていますが、現在のバージョンでは、Devices ではなく GameplaySystem, Physics などのフォルダに分散しています。

検索すればいいのですが、とにかくフォルダも中のアセットも数が多いので大変そうです。

このようなリファレンスも作ってくださっているので、うまく利用したいです。
フォートナイト クリエイティブとUEFNで使える仕掛け一覧|ゲームメーカーズ

スケルタルメッシュのマテリアルがすぐに有効にできなかった

書籍やサイトの通りに進めると、デフォルトマテリアルがメッシュに設定されてしまいます。 エラーメッセージから bUsedWithSkeletalMesh=True が無いということがわかりました。結果的には、マテリアルで以下のオプションにチェックを入れるだけだったのですが、「自分のインポート方法が悪いのかなと」いうところばかり疑っていたので、少し時間を要しました。

レベルを変更して、Fortnite 側に反映されるタイミングがよくわからない

UE5 でいうところの PIE のようなものが、Fortnite 側での実行になるのですが、一度終える場合には Fortnite 側で「ゲームを終了」だけして、UEFN とのセッションは継続しておき、UEFN 側でレベルを変更したら「ゲームを開始する」としていたのですが、たまに反映されないことがありました。

その場合は「変更をプッシュする」や「更新」などをするのですが、そうするとセッションが一度切断されるので、結構な時間がかかります。

そんなものかなとも思っているのですが、開発体験としてはあまりよくないので、もしかすると私の知らないいい方法があるのではと思っています。

初めての Verse プログラムを変更して実行する | Unreal Editor for Fortnite ドキュメンテーション | Epic Developer Community

その後、上記のドキュメントを見ていて、「エディタで加えたすべての変更」の反映には「変更をプッシュ」しかなさそうだなと理解できました。

移植できそうだったか

コースを作るスプラインに関して

UE5 側では Spline コンポーネントを持った Blueprint でコースを作っていたのですが、UEFN 側ではそれがぱっと見つからりませんでした。ここに関してはあまり深く調査していないので、方法はあるのかもしれません。

とりあえずランドスケープのスプラインで作ってみました。

一方で、アセットの移行なんかは、UE5 の Migrate で直接放り込めたのでとても楽ですね。

アセットを Unreal Engine から移行する | Unreal Editor for Fortnite ドキュメンテーション | Epic Developer Community

プレイヤーをコースで滑らせられるか

UE5 側ではプレイヤーの Movement の DecelerationFriction を操作して滑り続けるようにしていたのですが、UEFN 側ではプレイヤーキャラクターに対して同じことをする方法がぱっと見つかりませんでした。

一方で、スライダーに対して物理マテリアルの Ice を適用すると、プレイヤーが氷の上を滑るような感じにできることがわかりました。

ここまでで・・・・

入門から、多少の移植作業をしてみましたが、ここからは UEFN の仕掛けや Verse でのスクリティングを学ばないと「何ができるのか?」を調べることもままならないなと感じ一旦ストップです 😵

UEFN 入門してみて

久しぶりに Fortnite も開いてみて「うわ、なんだこれ Roblox みたいじゃん」とトップ画面を見て驚きました。

UEFN は UE5 と似てはいますし取っつきやすいですが、これはこれである種の別物として向き合わないといけないなとも思いました。

UE5 でポカポンぽいゲームを作ってみる ~プロトタイピング~

ポカポン

epoch.jp

私も子供の頃の淡い記憶でしかないのですが、小学生の頃からファミコンスーファミはあったものの、まだこういうファミリーゲームも家にはありました。

今回はこの感じを目標に作ってみたいと思います。「プロトタイピング」編としていますが、これで終わるかもしれません。

記事投稿時点の状態

youtu.be

コードは以下です。 UE 5.3.2 で作成しています。追加のプラグイン等はありません。

Release Blog post 20240604 · dany1468/Pokapon_UE · GitHub

作成過程

  1. Modeling Tool でキャラクターの Static Mesh を作る
  2. BP で Static Mesh を合わせてキャラクターの動きを作る
  3. レベルに配置する
  4. キーボードで操作できるようにする

以上のような過程で作っていきました。

1. Modeling Tool でキャラクターの Static Mesh を作る

UE5 で Skeletal Mesh Editing Tool を使って、物理挙動で動くだけのキャラクターを作る - You are done!

以前のブログでも Modeling Tool で簡単なキャラクターを作りましたが今回も以下のように単純なものです。

上記のコードでは CharacterCreationMap という専用のレベルで作成しています。

正面 右側面

腕の Pivot

Physics Constraint をこの後使うため、腕の Pivot だけは回転軸あたりに設定しています。

2. BP で Static Mesh を合わせてキャラクターの動きを作る

コードでいうところの BP_PokaponPawn です。名前の通り Pawn をベースにしています。

コンポーネントも、各腕に対してのハンマー、盾以外はフラットに並んでいます。

PC_Physics Constraint Component で、各パーツを繋いでいます。PC_Body_Outer だけは、Body に対して外部の設置した箇所に接続するため、接続コンポーネントが片方空いています。

細かい部分はコードをみてもらった方がいいのですが、左右の手の Physics Constaraint だけ見ておきます。それぞれ、角度の対して制約をかけています。

PC_RightHand_Body PC_LeftHand_Body

とはいえ、最初の動画を見てもらって分かる通り、数値はかなり雑に設定してあるものです。

最後に Event Graph ですが、ハンマーと盾をそれぞれ押し下げる動きを作っているだけです。

3. レベルに配置する

Github のコードでいうところの PokaponTestMap が対象になります。

Outliner Viewport

BP_Camera があるのが分かります。これは Camera Component を持つだけの BP です。
Auto Possess Player を Player 0 にしてあります。このカメラがこのレベルでのプレイヤー扱いです。

BP_PokaponPawn, BP_PokaponPawn2 はそれぞれに Player1, Player2 のタグを付けてあります。

4. キーボードで操作できるようにする

現状 PIE でスタートしても何も動かせないので動くようにします。

Player Controller を作成します。PC_Pokapon という名前にしてあります。

Begin Play
Inputs

ゲーム開始時に Player1, 2 を Tag から検索して参照を保存し、Input があった時にはそれぞれに対してイベントを呼び出しています。

当然ながら Enhanced Input のファイルも作成してありますが、特に工夫はないので割愛します。Github の方には Inputs フォルダがあるので、そこを見てください。

まとめ

Phyisics でゲームを考える時に、どうしても現実にあるものをベースに考えてしまうのですが、レトロゲームはとてもルールもよく考えられていて素晴らしいなと改めて思うのでした。

Unity Learn の Behaviour Trees でビヘイビアツリーの簡単な実装を学んだ

動機

Unreal Engine を利用していると、標準機能として Behaviour Tree が組み込まれており、その記憶領域である Black Board も利用することができます。(今回は関係ありませんが、センサー系も組み込まれいるので本当に便利です)

Unity で同じことをやろうとすると、以下のようなアセットや OSS を利用する必要があると認識しています。(新しいバージョンだと使えるとかあれば調査不足ですスミマセン 😫)

以下はぱっと見つけられたものだけです。

可視化できるものや、グラフエディタとして編集できるもの、専用の DSL を備えるものまで様々です。OSS 系では C# でツリー構造を記述していくものがほとんどになります。

これらを利用するにしろしないにしろ、 Behaviour Tree というものの基本的なこと を一度簡単な例で押さえておきたいと思い、掲題のチュートリアルをすることにしました。

チュートリアル

learn.unity.com

最終的なコードが見たいだけの場合

チュートリアルの最後のチャプターに完全版のコードがダウンロードできるようになっています。

Conditions - Unity Learn

チュートリアルの簡単な流れ

以下のようなレベルが用意されています。

レベル NavMesh 可視化

左下に選択状態のオブジェクトがありますが、これが「泥棒」NPC であり、これを Behaviour Tree を使って操作します。

NavMesh が設置してあり、NPC にも Nav Mesh Agent がついています。

半透明のドアが2個所あるのが分かりますが、これらのロック状態を確認し、室内のダイアモンドを取得して、また車の場所まで戻ってくるタスクになります。

最終的なツリー構造は以下のようになります。(以下の図は、チュートリアル内のものです)

ノードにある「」は Sequence です。配下のノードを順番に成功する限り実行し続けます。
?」は Selector です。配下のノードを順に実行し、一つでも成功すれば終了です。
この辺は UE と用語も一致します。

Condition となっているものは、実行条件を付けるものであり、Sequnce の先頭におくことで Sequence 自体を実行するかどうかを判定するのに使えます。UE の場合であれば Decorator が近いと思います。

ざっくり各コード

このチュートリアルでは、以下のクラスが出てきます。

  • Node: 各ノードのベースになるもの
  • Leaf: 末端ノード。実行するべき処理が入る。
  • Sequence
  • Selector

これだけです。Condition に関しては「何も実行せず、判定だけする Leaf」として表現されています。

以下のコードは省略もあるので、全文が見たい場合はチュートリアルの方からコードをダウンロードしてください。

public class Node
{
    public enum Status { SUCCESS, FAILURE, RUNNING }
    public Status status;

    public List<Node> children = new List<Node>();
    public int currentChild = 0;

    public string name;

    public Node() { }

    public Node(string name)
    {
        this.name = name;
    }

    public virtual Status Process()
    {
        return children[currentChild].Process();
    }
    
    public void AddChild(Node child)
    {
        children.Add(child);
    }
}

まずは Node です。ツリー構造を作るので Composite パターンを想像してもらえると実装がわかりやすいです。子を持ち、それを追加(削除)可能で、Leaf で実行すべき Process を持っています。

よく見る Composite パターンの例と異なるのは、子の実行をループで実行するのではなく currentChild のインクリメントを外部に移譲している点です。

これは、後述の Sequence , Selector で子を実行する処理が変わってくるためです。

public class Leaf : Node
{
    public delegate Status Tick();

    public Tick ProcessMethod;
    
    public Leaf(string name, Tick processMethod)
    {
        this.name = name;
        ProcessMethod = processMethod;
    }

    public override Status Process()
    {
        return ProcessMethod?.Invoke() ?? Status.FAILURE;
    }
}

続いて Leaf です。
Tick が出てきていますが、ProcessMethod と名前が付いている通りで、実行する処理を外部から受け取っているだけです。Status を返す関数を受け取るので Process でそのまま実行結果を return できています。

public class Sequence : Node 
{
    public Sequence(string name)
    {
        this.name = name;
    }

    public override Status Process()
    {
        Status childStatus = children[currentChild].Process();
        
        if (childStatus == Status.RUNNING) // すでに進行中ならそのまま
            return Status.RUNNING;

        if (childStatus == Status.FAILURE) // 子が一回でも FAILUER を記録したら、この Sequence も FAILURE を返却し終了
            return childStatus;

        currentChild++;
        if (currentChild >= children.Count) // 最後まで到達したら正常終了
        {
            currentChild = 0; // 次回のために初期化
            return Status.SUCCESS;
        }
        
        return Status.RUNNING;
    }
}

Sequence です。
コードコメントである程度書いていますが、子の実行結果が失敗になった時点で自身も失敗にします。すべての子が成功すれば自身も成功です。

public class Selector : Node
{
    public Selector(string name)
    {
        this.name = name;
    }

    public override Status Process()
    {
        Status childStatus = children[currentChild].Process();
        
        if (childStatus == Status.RUNNING) // すでに進行中ならそのまま
            return Status.RUNNING;

        if (childStatus == Status.SUCCESS) // 子が一回でも SUCCESS を記録したら、この Sequence も SUCCESS を返却し終了
        {
            currentChild = 0; // 次回のために初期化
            return Status.SUCCESS;
        }

        currentChild++;

        if (currentChild >= children.Count) // 最後まで到達したら失敗として終了
        {
            currentChild = 0; // 次回のために初期化
            return Status.FAILURE;
        }

        return Status.RUNNING;
    }
}

Selector です。
Sequence と対比すると分かりやすいですが、こちらは子が一つでも成功すれば自身も成功として即終了です。
逆に、最後まで到達してしまうと失敗という扱いになります。(最後の子が成功していればその前の if で成功終了しているはず)

最後に BehaviourTree です。これは全体のルートを表します。ここでは割愛しますが、子ノードをプリントする機能を入れています。

public class BehaviourTree : Node
{
    public BehaviourTree()
    {
        name = "Tree";
    }

    public BehaviourTree(string name)
    {
        this.name = name;
    }
    
    public override Status Process()
    {
        return children[currentChild].Process();
    }

泥棒用の Behaviour Tree の例

次にチュートリアルで作成した Behaviour Tree を確認します。

public class RobberBehaviour : MonoBehaviour
{
    private BehaviourTree tree;

    void Start()
    {
        agent = this.GetComponent<NavMeshAgent>();

        tree = new BehaviourTree(); // root ノード

        // Open Door の Selector
        Selector openDoor = new Selector("Open Door");
        openDoor.AddChild(new Leaf("Go To FrontDoor", GoToFrontDoor));
        openDoor.AddChild(new Leaf("Go To BackDoor", GoToBackDoor));

        // Condition
        Leaf hasGotMoney = new Leaf("Has Money", HasMoney); 

        // Steal の Sequence
        Sequence steal = new Sequence("Steal");
        steal.AddChild(hasGotMoney);
        steal.AddChild(openDoor);
        steal.AddChild(new Leaf("Go To Diamond", GoToDiamond));
        steal.AddChild(new Leaf("Go To Van", GoToVan);

        tree.AddChild(steal);
    }

    // GoToFrontDoor / GoToBackDoor / HasMoney / GoToDiamond / GoToVan のメソッドもこのクラスに配置

図と比べてもらうと分かりやすいですが、コードで木構造が表現できているのが見て取れます。

ここまででは Nav Mesh Agent をどう使っているかが全く出てきいません。
最後にその部分に触れておきます。というのも、Behaviour Tree 自体は Nav Mesh とはなんの関係もなく、この Behaviour Tree を何に使うかは自由であるので、あえてここまでは除外していました。

private NavMeshAgent agent;

public enum ActionState { IDLE, WORKING }
ActionState state = ActionState.IDLE;

Node.Status GoToLocation(Vector3 location)
{
    float distanceToTarget = Vector3.Distance(location, this.transform.position);

    if (state == ActionState.IDLE) // 初期は IDLE なので Agent に目的に向かうように指示し、WORKING に変える。ノードのステータスも RUNNING となる。
    {
        agent.SetDestination(location);
        state = ActionState.WORKING;

        return Node.Status.RUNNING;
    }

    // 以降は ActionState が WORKING、つまり泥棒が目的地に向かっている最中 or 停止

    if (Vector3.Distance(agent.pathEndPosition, location) >= 2) // 停止したが、目的地に到達していない
    {
        state = ActionState.IDLE;
        return Node.Status.FAILURE;
    }
    else if (distanceToTarget < 2) // 目的地と泥棒の距離が到達とみなしていい距離になった
    {
        state = ActionState.IDLE;
        return Node.Status.SUCCESS;
    }

    return Node.Status.RUNNING;
}

GoToLocation は前述の RobberBehaviour 内のメソッドです。これは、GoToFrontDoor などの GoTo 系のすべてで利用しており、簡単には「目標地点に到達しているかどうかでノードのステータスを返す」ものです。
ノードのステータスとは別に、動き出したかどうかを判定するのに ActionState が導入されています。

コードコメントにある程度記載しましたが、簡単には初回の GoToLocation で、ActionStateWORKING になります。これと同時にノードのステータスも RUNNING になります。

泥棒が移動中も常にチェックが入ります。そして、なにかに引っかかるなどで停止し pathEndPosition が目的地に届かないとなった時点で「失敗」とみなします。

逆に泥棒と目的地の距離が十分に近くなれば「成功」とします。いずれでもなければ RUNNING のままです。

ちなみにこの 2 は今回のサンプルのみで通用するので、キャラクターや目的地によっては別の距離を指定する必要があります。

結局どこでループしているか

public class RobberBehaviour : MonoBehaviour
{
    private BehaviourTree tree; // ルート
    Node.Status treeStatus = Node.Status.RUNNING;

    void Update()
    {
        if (treeStatus != Node.Status.SUCCESS)
            treeStatus = tree.Process();
    }

ループ自体は RobberBehaviour が担当しています。
ツリーのルートの Process を呼び出し続けることで、各ノードの現在の子に伝搬していきます。結果、ここまでの構造がキレイに噛み合うようになります。

各ノードはあくまで「自身の保持する currentChild 番目の子ノードを実行 (Process) するだけ」という状態になっているわけです。

まとめ

「ゲーム AI 技術入門」でもビヘイビアツリーのことが記載されていますが、そこでは「プライオリティ」や「ランダム」が登場します。シーケンスも含めて、「選択ルール」によって「子ノード競合モデル」という方法で「どの子ノードを実行するか」を決定しているのだそうです。

「プライオリティ」は「子ノードのうちアクティブ可能なものを優先順に沿って選択する」ような感じなので、チュートリアルで作成した Selector とは少し異なる感じもします。

とはいえ、今回のチュートリアルを通して、アセットや OSS を見た時に基本的なことは理解しながら扱える状態になったのではないかと思います。

UE の GAS Companion と Blueprint Attributes プラグインを試す

UE の GameplayAbilitySystem (GAS) はとても便利です。

一方で、私はまだまだ理解も浅いです。Lyra や古代の谷などのサンプルの他に、以下のドキュメントが参照先として紹介されるのを見ることがありますが、いつも見ては忘れ、見ては忘れという感じです。

以前は C++ がなければ使えないような機能があり、その当時から GAS Companion を使っていたのですが、同じ作者が Blueprint Attributes というプラグインを公開しているのを知り、今回はそれらを連携させてどういうことができるか試してみました。

Plugins

以下にマーケットプレイスのリンクと Description に加えて、公式のドキュメントとサンプルプロジェクトを一覧します。
いずれもとても丁寧に作られ、更新されていますし Discord での質問も受け付けているようです。

どちらも、GAS で C++ が必要な部分を Blurprint のみで実装可能にしてくれるものではありますが、それに加えて前述の GASDocumentaion で推奨されるようなパターンを考慮した形になっているようです。

今回試すこと

GAS Companion だけでも GAS を使う分には十分なのですが、Blueprint Attributes を使うことで、GAS の Attribute Set を Blueprint で作成することができます。今回はその点を中心に試していきます。

GAS Companion だけだと、https://gascompanion.github.io/api/Abilities/Attributes/GSCAttributeSet/ にある基本的な属性しか利用できず、追加するためには C++ での実装が必要になります。 とはいえ、Attributes Wizard にあるようにウィザードまで用意されているので、「やれば」的な感じではあるのですが。

実際に試す

とりあえず必要なものを揃える

BP_GASStudyCharacter : Thirdperson をコピーしたもので、 GSCModularCharacter に Parent を変更してあるだけです。

以下は、公式のサンプルプロジェクトに入っている Utility のコードなのですが、便利なのでとりあえずコピーします。(一番下の Home ボタンのは私が足したので、不要だと思います)

今回は PlayerController (PC_GASStudy) に貼りました。

まずは GAS Companion だけで Attributes を設定する

BP_GASStudyCharacter の方に設定するだけです。 GSCAttributeSet は GAS Companion からデフォルトで提供されています。

この状態で PIE で Enter を押してデバッグ情報を表示すると以下のようになり、 GAS Companion が提供する AttributeSet が設定できていることが分かります。

UI を付ける

Working with UI

デフォルトの AttributeSet であれば、こちらもデフォルトで提供されている Widget を利用できます。今回は WB_HUD を使いました。

上記を PC_GASStudy に設定すると期待通り表示を確認できます。

自分で UI を作る

先ほどのデフォルトの Widget の親クラスになる GASUWHud を継承して作成できます。

https://gascompanion.github.io/api/UI/GSCUWHud/

今回は WBP_CustomGSCUWHud と名前を付けました。これを先ほどの WB_HUD の代わりに入れても当然何も表示されません。

Widget の内部を作成

https://gascompanion.github.io/working-with-ui/#designing-your-own-widget

ドキュメントの通りですが、デフォルトで提供されている AttributeSet はすでに C++ 側で Text なども準備されており、それをレイアウトするだけになります。今回は Progress Bar は無視して Text 系だけ同じ名前で設定します。

これだけで以下のように UI が表示されました。現状 Max も Current も 0 なので 0 / 0 のような表示になります。

デフォルトの Attribute Set に値を入れる

https://gascompanion.github.io/quick-start/#initialization-with-data-table

ドキュメントの通りですが、Data Table を以下の構造を使って作成します。

DT_Study_Attributes という名前にしました。内容は以下です。ここも Row Name は決まっています。

先ほどの BP_GASStudyCharacter に設定した箇所の下に Data Table を設定できるので設定します。

設定 表示

UI をカスタムし、自分で値を取得し表示する

さて、自動で値が設定されるのは便利なのですが、ここから Attribute を追加したいので、まずは手動で表示してみます。

https://gascompanion.github.io/api/Components/GSCCoreComponent/

そのためには上記の Component を追加する必要があります。

先程の Widget に一列追加し、手動表示の列とします。

tree view

Widget 側の Construct で以下のように初期値を表示してみます。(以下は簡略化のために Health だけです)

お気づきの通りで、 Health など、デフォルトで用意される Attribute に関しては専用の取得関数も用意されています。

以下のように最終列に手動での表示ができました。

UI をさらにカスタムして使う

先ほどは GSCUWHud を継承して使いましたが、見てきた通りでデフォルトで提供される Attribute の Text 等がすでに入っていました。

今回はそれも入っていない GSCUserWidget を使います。

https://gascompanion.github.io/api/UI/GSCUserWidget/

WBP_CustomGSCUserWidget という名前にしました。とりあえず、先程の WBP_CustomGSCUWHud の UI の内容だけコピペしておきます。

次に、スクリプトもコピーしようとしますが、Get Owning Core Component が存在しないエラーになります。

Set Owner Actor を使う

https://gascompanion.github.io/api/UI/GSCUserWidget/#setowneractor

つまり、まだ WidgetGSCCoreComponent を持つ Owner Actor を知らないということです。

とりあえず、Widget のコンストラクタで設定してみます。

うまくいきました。

さて、ここまでで Blueprint Attributes で新しい Attribute を追加した時に表示できそうな準備ができました。(デバッグ表示だけでも良かったのですが。)

Attribute を Blueprint Attributes で作る

コンテンツブラウザから簡単に作れます。 GBA_Study_Attributes という名前にしました。

Armor / MaxArmor を作ります。

Blueprint Attributes には Gameplay Clamped Attribute Data が追加されており、これを利用することで簡単に最大値・最小値に clamp してくれる Attribute を作れます。

さて、この新しい Attribute Set もこれまでと同様に BP_GASStudyCharacter に設定します。

まずはデバッグで確認しましょう。OKですね!

追加した Attribute を UI に表示する

WBP_CustomGSCUserWidget の方で表示してみます。

UI に関しては単にグリッドの最終列に追加しました。

さて、表示ですが Health 等と違って専用の取得ノードは用意されていません。よって以下の Get Current Attribute Value を利用します。

以下のように表示することができました!(Armor の Base 値を 60 にしてしまったので、先程と値が変わっています。)

試してみて

今回はそれぞれのプラグインについてではなく、簡単にそれぞれがうまく噛み合うか確認しました。

GAS Companion と Blueprint Attributes は作者が同じということもあり、ある程度の棲み分けも考えて作られていると思いますが、どちらか単独でも十分に機能するように作られているように思います。

記述できる場所が違う( C++ か BP か、だけでなく)けれど同じことはできるような感じです。一方でこれは GAS を UE のデフォルトだけで書くのも同じ話ではあるので、このプラグイン達を便利に感じるかどうかはチームによると思います。

個人的には、サンプルも通して GAS の使い道を考える上で勉強になることが多かったです。それほど高額なプラグインでもないので検討に値するのではないかと思います。

UE5 で Skeletal Mesh Editing Tool を使って、物理挙動で動くだけのキャラクターを作る

第21回UE5ぷちコン|株式会社ヒストリア で以下の作品を提出しました。

第21回UE5ぷちコン 提出動画「Hand Push Sumo」 - YouTube

このゲームでは、Box や Sphere だけで構成された簡素な物体がプレイヤーキャラクターとなっています。

ただボタンを押したらキャラクターが腕を伸ばして相手を押すという、いわばレトロな対戦パンチゲームのような雰囲気を出すには、これぐらいがちょうどいいだろうという意図での選択でした。

時間が無くてプロトタイプで作ったキャラクターで進めるしかなかったというのもあります 🤣

このキャラクターはアニメーションは一切使っておらず、 Simulate Physics を有効にした状態で、ただ空中に浮いた腕を前に押し出す(そして自動で戻る)ということだけを行っています。

本記事では、このキャラクターを UE5 のみ で作成した過程を簡単にご紹介します。(本記事はコードがほとんど無いのでサンプルプロジェクトはありません)

空のステージを用意する

これは単に作業がしやすいからです。

既存のレベルを使うにしても、原点で作業した方が数値の設定がわかりやすいので良いとおもいます。

Modeling Tool でキャラクターの Static Mesh を作る

まずはキャラクターのメッシュを作ります。Modeling Mode に切り替えましょう。

1. Box 配置

何も無いので、まずは「Body」になる Box を配置します。

座標は原点にしておきます。

2. Body を Triangle Edit で調整

Mesh メニューにある、Triangle Edit で Body を調整していきます。

一つの面は三角が2つあるので、それらを選択し移動させて形状を変えていきます。今回は以下のように横長の形にしています。

また、今回は X がキャラクターの前面として作っているため、上記でも長いのは Y 方向です。

5. Sphere を設置し、縮める

これは頭部になります。今回は 0.6 倍しています。Pivot が下にあるので位置を最初に決めることができます。

初期サイズ 0.6倍後

6. 腕を作る

こちらも Box で作ります。Body 同様に形状を変えて、 最後に Pivot を変更します。

a. 形状変更 b. Pivot変更
c. 複製して両側に d. 少し Body から離す

Pivot 変更 (Edit Pivot) は XForm メニューにあります。

7. Merge して一つのメッシュにする

ここまでで作成したすべてのメッシュを選択した状態で XForm メニューの Merge でまとめます。

Pivot が真ん中になっていると思うので、足元 (原点で作業しているなら原点) に変えておきます。

そうすると、Outliner の表示が Combined の一つになっており、またそのメッシュ本体も以下のようにレベルがある場所と同じところの _GENERATED フォルダ内に保存されているはずです。

Contents Browser の Thumbnail Edit Mode でサムネイルの見た目を変えておくと便利です。
また、ここではこの Static Mesh の名前を SM_Study と名称を変更しています。

Skeletal Mesh Editing Tool でスケルタルメッシュを作る

1. プラグイン有効化

UE5.3.2 の時点ではまだデフォルトで有効では無いの有効化します。

  1. スケルタルメッシュに変換

Contents Browers で先程の Skeletal Mesh のコンテクストメニューから変換を実行します。

今回は Create New です。

そうすると、Skeleton と Skeletal Mesh が作成されます。

  1. Skeletal Mesh を開いて Root を確認

Pivot の変更がされていれば Root が以下のように足元になっているはずです。

  1. Skeletal Mesh の編集

引き続き Skeletal Mesh の画面です。
左のメニューに Skeleton があるはずです。もしなければ以下の上部の Editing Tools が有効になっているか確認してください。

さっそく Edit Skeleton してきます。

  1. Bone をつけていく

Edit Skeleton した状態で右ペインの Skeleton Tree にいくと、New Bone ができるようになっています。ボタンからでも Root のコンテクストメニューでも大丈夫です。(Edit Skeleton しないと New Bone が出ません)

最初は慣れないかもしれませんが、New Bone すると新しい joint が作成されるので、それを選択した状態で移動させます。

今回は最終的に以下のようにしました。

upper_arm_, lower_arm_ に関しては、Left 側だけを作って Mirror して複製しています。

Left 側を作り選択 Mirror を実行
複製される

Mirror に関してはデフォルトでは X 軸で Mirror されるようになっているので Y に変更しています。

6. Skin を付けていく

Skin に関しては 左クリックで塗るCtrl + 左クリックで消す だけここでは使います。

では、左の Skin メニューから、一度 Bind Skin し、そのまま Edit Weights していきましょう。

作成直後はこんな感じです。

今回は以下のようにしています。

Root spine_01 neck head
upper_arm_l lower_arm_l upper_arm_r lower_arm_r

上段は、Bind Skin したままです。
下段の arm 系は自身の arm 以外の weight を 0 に塗り直しています。それだけです。

私も Skin Weights は意図なくやっているので、参考にはならないと思います 😵

7. 確認する

Skeleton の画面に移動して、upper_arm_l などを動かして見ると、Skin Wieght の状況が確認できると思います。

ここまでで、Skeletal Mesh Editing Tool を使う部分は終わりになります。

Physics Asset を付ける

Physics Asset での目標は、「腕だけ前後に振る動きができる」というものです。

1. 生成し大きさを調整する

Contents Browser の Skeletal Mesh のコンテクストメニューから作成できます。

初期の生成メニューでは Capsule から box にだけ変えます。

生成直後は以下のような感じです。

まずは head だけ Sphere で作り直します。おそらく head と同じサイズになります。

以下のように大きさをメッシュに合わせます。neck 部分は少し spine_01 (body) を小さくして head の下に差し込んでいます。

spine_01 は足元まで延ばしておきます。Simulate Physics を ON にした時に倒れないようにするためです。

2. Constraint を調整する

upper_arm_l(r) に関するもの以外の Constraint を以下のように角度もロックします。

続いて upper_arm_l(r) に関する Constraint の設定です。以下に右側の例だけ示します。

対象 設定 状態

ただ、Constraint の方向によっては Angular Limits の設定だけをしても方向が異なる場合があります。
その場合は、回転ツールで Constraint 自体を回転させたり、Swing2 以外にしてみるなどしてみてください。

目的としては、腕の動きを下方向の X 方向にだけ限定したい というだけです。

Blurprint で動かす

1. 基本的な設定

作成した Skeletal Mesh を使って Pawn をベースにした BP を作成します。

Components Viewport

当然ながら SkeletalMesh の Simulate Physics は ON にしておきます。

2. Event Graph

腕を前に押し出すだけです。

3. Level Blueprint から呼び出す

先ほどの Pawn に Posses してもいいのですが、今回は雑に Level Blueprint から呼び出します。

レベルに配して参照を直接取得して以下のようにします。

4. 動作確認する

今回は以下のような動きになることを確認しています。

youtu.be

まとめ

Skeletal Mesh Editing Tool を使うことで、これまで Blender 等でする必要があった作業が(どの程度かはわかりませんが)代用できるようになりました。

まだ Experimental で発展途上だとは思いますが、可能性を感じる機能だと思います。

参考記事