-
Notifications
You must be signed in to change notification settings - Fork 1
Creating new addons
The content editor expects a specific flow for optimal behavior for addons. The gist of it is:
- an addon registers its entity types (
udb.register_entity_type('entity_type')) - the content editor waits for at least one frame to ensure all installed addons managed to execute, and then for some of the game data to be properly setup (in the rare case of super early REF startup)
- the content editor emits the 'get_existing_data' event, at this point every addon that is listening to this event should fetch any existing data from the game catalogs etc
- after that, all activated bundles are loaded in the user defined order
- the importer function is called for each imported entity, given both the import data and the existing instance as a parameter (so there's no need to keep separate track of objects)
Every editor will generally have 4 parts to it. Depending on the specific type of data, some of these steps can work differently or be done in the same step.
- getting existing game data
- updating game data
- data serialization (import/export)
- editor UI
For a holistic view, it may help to look at some of the existing editors, e.g. https://github.com/kagenocookie/dd2-content-editor/blob/0dfc82668e4d2848bbdff3370e62842bdb247cf5/reframework/autorun/editor_shops.lua.
Additionally, all of the content editor API is accessible globally through the usercontent variable. It can be used for prototyping through the console or easier access to any part of the API.
Start by defining the entity that is to be edited. The minimum requirement is having a way to get some sort of unique integer ID. Most objects in RE engine tend to have some sort of numerical ID (like an enum), in which case using that is the best solution. Failing that, sometimes there's a System.Guid instead, which could be converted to an integer maybe through the mData4L field. If there is neither then maybe have some combination of fields that are checked for equality when attempting to modify or add new objects.
DD2 shop basic example:
local udb = require('content_editor.database')
local import_handlers = require('content_editor.import_handlers')
udb.register_entity_type('shop', {
export = function (instance)
return {
-- this automatically converts the app.ItemShopParam instance to a json compatible object
data = import_handlers.export(instance.runtime_instance, 'app.ItemShopParam')
}
end,
import = function (data, entity)
-- the entity instance received here can be either an empty one when creating a new entity (meaning it only has the ID and type set), or a full existing one
-- this converts the json object from the export function back into the game class structure
-- by passing in the existing runtime instance in the 3rd parameter we already update all the data into the instance if it exists
entity.runtime_instance = import_handlers.import('app.ItemShopParam', data.data, entity.runtime_instance)
-- if the shop does not exist and the game stores them in a dictionary, then we'd add it to its catalog here, this here is just illustrative and not actually functional
if not ShopManager._ShopData:ContainsKey(data.id) then
ShopManager._ShopData[data.id] = entity.runtime_instance
end
end,
-- the content editor supports different kinds of delete types depending on what is possible to do safely, if no delete is supported the method can just be left unspecified, which will be treated as not deletable.
delete = function (entity)
return 'not_deletable'
end,
-- generates an automatic label for the entity, if you want to show more than just entity type + ID, maybe add the name of the object or similar
generate_label = function (entity)
return 'Shop ' .. entity.id .. ': ' .. entity.runtime_instance:get_ShopName()
end,
-- we need to define a range in which custom object IDs can be generated. This should be defined such that it minimizes the odds of conflicting with base game objects
-- if the IDs are sequential, maybe just make the range start at something at least 3x higher than the basegame max id
-- if the IDs are randomized (hashes), consider making the IDs lower than the lowest non-zero hash number
insert_id_range = {1000, 999000},
-- the types for which to pre-generate type cache data, should basically be a list of types that the entity references
root_types = {'app.ItemShopParam'},
-- if the IDs are based on an ingame enum, it should be set here, otherwise can be left unset
-- this will also replace the selections on any reference of the enum with the entity labels and include any custom entities
replaced_enum = 'app.QuestDefine.ID',
})Getting game data:
- if the data is accessible globally (e.g. from a singleton), you can use the
get_existing_dataevent - if the data is loaded by the game on demand as needed, a hook will be needed that registers and/or updates the entity
The easiest case is when the data is globally accessible from a catalog. If there's multiple object related to the entity sharing the same ID, they should be fetched and grouped in here as well before being registered. Example:
local udb = require('content_editor.database')
udb.events.on('get_existing_data', function ()
local ItemManager = sdk.get_managed_singleton('app.ItemManager')
local dataRoot = ItemManager.ItemShopData ---@type REManagedObject|table
for _, shop in ipairs(dataRoot._Params:get_elements()) do
udb.register_pristine_entity({
id = shop._ShopId,
type = 'shop',
runtime_instance = shop,
})
end
end)For updating the data, usually I find that if it's a System.Collections.Generic.Dictionary or System.Collections.Generic.List, I just add new entries during the entity's import method. If it's an array, then the entities_created and entities_updated events can be used instead, since arrays are immutable, so we don't create new arrays for each single imported entity.
data contains an object in the structure {[entity_type] = {entity1, entity2}}, with a separate array for each unique type of entity that was imported in this batch. During a full bundle load, the event is triggered only for entities whose instance changed during the bundle import, meaning either a new entity that is not yet present in the game data, or a new entity instance was created/replaced. It is also triggered for any single load or reimport of an entity.
local udb = require('content_editor.database')
local helpers = require('content_editor.helpers')
udb.events.on('entities_created', function (data)
if data.shop and #data.shop > 0 then
local shopInstances = utils.pluck(data.shop, 'runtime_instance')
-- this function may also want to filter out instances that are already stored in the array
ItemManager.ItemShopData._Params = helpers.expand_system_array(ItemManager.ItemShopData._Params, shopInstances, 'app.ItemShopParam')
end
end)So far I've found two patterns
- all the data is loaded on game load and just not yet accessible in the main menu (for example, quest data)
- each individual entity is loaded on demand as needed (enemy spawns, enemy data, objects tied to specific areas)
For quests, the data is globally accessible but only after the game is loaded. This means we can find all the data by going through the quest root scene. Example: https://github.com/kagenocookie/dd2-content-editor/blob/master/reframework/autorun/quest_editor/quest_processors.lua
For others, the entities only get loaded individually and are not globally accessible. In this case, the following update hook could also serve as the get hook.
For updating, both of these cases need to define a hook at which point the data should get injected. Depending on how the method works, the real logic may need to be either in the prehook or the post hook.
Do note that for hooks that get called during gameplay and not just load screens, it may end up hurting performance if it's a lot of data that gets imported each time, since the default importer does a full import and export of all the data. The better solution then may be to do some manual work to only modify the fields that actually matter instead of relying on the content editor provided automatic serialization of entities.
DD2 quest processors example:
local udb = require('content_editor.database')
sdk.hook(
sdk.find_type_definition('app.ProcessorFolderController'):get_method('collectProcessors'),
function (args)
thread.get_hook_storage().this = sdk.to_managed_object(args[2])
end,
function (ret)
local this = thread.get_hook_storage().this
local questId = this._QuestController._QuestID
--- @type QuestProcessorData[]
local editedProcessors = udb.get_entities_where('quest_processor', function (proc)
--- @cast proc QuestProcessorData
return proc.raw_data.questId == questId
end)
for _, proc in ipairs(editedProcessors) do
-- implementation here is deferred to a separate function, exact implementation entirely depends on the specific entity
if not proc.disabled then
importer.quest.processor(proc, this)
end
end
return ret
end
)Example for enemy spawns:
local udb = require('content_editor.database')
local import_handlers = require('content_editor.import_handlers')
sdk.hook(
sdk.find_type_definition("app.EnvironmentSceneController"):get_method("requestGenerateData()"),
function (args)
local EnvSceneController = sdk.to_managed_object(args[2]) --[[@as app.EnvironmentSceneController]]
local table = EnvSceneController._ManualSetTableListData
local id = table._Guid.mData4L
local entity = udb.get_entity('enemy_spawn_table', id)
if entity then
-- update the instance reference on the content editor entity
entity.runtime_instance = table
-- this will update all the data with whatever's in our modded data
import_handlers.import('app.GenerateTableListData', entity.data, entity.runtime_instance)
else
entity = udb.register_pristine_entity({
id = id,
type = 'enemy_spawn_table',
data = import_handlers.export(table, 'app.GenerateTableListData', { raw = true }),
runtime_instance = table,
label = table:get_Path()
})
end
end
)Most of the editors are locked behind the core.editor_enabled setting which allows us to skip editor setup and optimize some things which may lead to better performance and less bug reports for users. This setting could also be used to skip registering unknown entities that are only needed when editing and reduce memory consumption on that front as well.
local core = require('content_editor.core')
local udb = require('content_editor.database')
if core.editor_enabled then
local enums = require('content_editor.enums')
local editor = require('content_editor.editor')
local ui = require('content_editor.ui')
-- this defines an editor window
editor.define_window('shop', 'Shops', function (state)
-- the state parameter can be used to store any semi-permanent UI settings and preferences
-- show a CREATE NEW button with a preset picker when a bundle is selected for editing
if editor.active_bundle then
local create, preset = ui.editor.create_button_with_preset(state, 'shop')
if create then
local newEntity = udb.insert_new_entity('shop', editor.active_bundle, preset or {})
ui.editor.set_selected_entity_picker_entity(state, 'shop', newEntity)
end
end
-- easy entity selector
local selectedShop = ui.editor.entity_picker('shop', state)
if selectedShop then
--- @cast selectedShop ItemShopData
imgui.spacing()
imgui.indent(8)
imgui.begin_rect()
-- this shows the select bundle, entity label and save buttons
ui.editor.show_entity_metadata(selectedShop)
-- pop the runtime object directly editable straight into the UI, including detection when changes are made
ui.handlers.show_editable(selectedShop, 'runtime_instance', selectedShop, nil, 'app.ItemShopParam')
imgui.end_rect(4)
imgui.unindent(8)
end
end)
-- show the editor as a tab on the main content editor window
editor.add_editor_tab('shop')
endFor changing how certain objects are serialized as well as UI display, the content editor supports additional overrides per class and per field to make things easier to deal with. See also https://github.com/kagenocookie/dd2-content-editor/wiki/UI-extensions.
local definitions = require('content_editor.definitions')
local enums = require('content_editor.enums')
local ui = require('content_editor.ui')
definitions.override('', {
['app.ItemShopBuyParam'] = {
fields = {
_ItemId = { uiHandler = ui.handlers.common.enum('app.ItemIDEnum') }
},
toString = function (value) return 'Buy ' .. enums.get_enum('app.ItemIDEnum').get_label(value._ItemId) end
},
['app.ItemShopSellParam'] = {
fields = {
_ItemId = { uiHandler = ui.handlers.common.enum('app.ItemIDEnum') }
},
toString = function (value) return 'Sell ' .. enums.get_enum('app.ItemIDEnum').get_label(value._ItemId) end
},
['app.ItemShopParam'] = {
fields = {
_ShopNameId = { extensions = { { type = 'translate_guid' } } },
},
},
})