Serialize entities to JSON

- Add JSON library that can marshal Noita entities and components
- Add Noita API wrapper that exposes entities and components as objects
- Change how the entities file is written, to support lightweight and crash proof appending of JSON data

#9
This commit is contained in:
David Vogel 2022-07-18 01:32:44 +02:00
parent 861272187a
commit 833ab41eeb
3 changed files with 716 additions and 19 deletions

View File

@ -3,6 +3,12 @@
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
---@type NoitaAPI
local noitaAPI = dofile_once("mods/noita-mapcap/files/noita-api.lua")
---@type JSONLib
local jsonSerialize = dofile_once("mods/noita-mapcap/files/json-serialize.lua")
CAPTURE_PIXEL_SIZE = 1 -- Screen to virtual pixel ratio. CAPTURE_PIXEL_SIZE = 1 -- Screen to virtual pixel ratio.
CAPTURE_GRID_SIZE = 512 -- in virtual (world) pixels. There will always be exactly 4 images overlapping if the virtual resolution is 1024x1024. CAPTURE_GRID_SIZE = 512 -- in virtual (world) pixels. There will always be exactly 4 images overlapping if the virtual resolution is 1024x1024.
CAPTURE_FORCE_HP = 4 -- * 25HP CAPTURE_FORCE_HP = 4 -- * 25HP
@ -84,18 +90,31 @@ local function captureScreenshot(x, y, rx, ry, entityFile)
-- Capture entities right after capturing the screenshot. -- Capture entities right after capturing the screenshot.
if entityFile then if entityFile then
local radius = math.sqrt(virtualHalfWidth^2 + virtualHalfHeight^2) + 1 local ok, err = pcall(function()
local entities = EntityGetInRadius(x, y, radius) local radius = math.sqrt(virtualHalfWidth^2 + virtualHalfHeight^2) + 1
for _, entityID in ipairs(entities) do local entities = noitaAPI.Entity.GetInRadius(x, y, radius)
-- Make sure to only export entities when they are encountered the first time. for _, entity in ipairs(entities) do
if not EntityHasTag(entityID, "MapCaptured") then -- Get to the root entity, as we are exporting entire entity trees.
local x, y, rotation, scaleX, scaleY = EntityGetTransform(entityID) local rootEntity = entity:GetRootEntity()
local entityName = EntityGetName(entityID) -- Make sure to only export entities when they are encountered the first time.
local entityTags = EntityGetTags(entityID) if not rootEntity:HasTag("MapCaptured") then
entityFile:write(string.format("%d, %s, %f, %f, %f, %f, %f, %q\n", entityID, entityName, x, y, rotation, scaleX, scaleY, entityTags)) -- Some hacky way to generate valid JSON that doesn't break when the game crashes.
-- TODO: Correctly escape CSV data -- Well, as long as it does not crash between write and flush.
EntityAddTag(entityID, "MapCaptured") -- Prevent recapturing. if entityFile:seek("end") == 0 then
-- First line.
entityFile:write("[\n\t", jsonSerialize.Marshal(rootEntity), "\n", "]")
else
-- Following lines.
entityFile:seek("end", -2) -- Seek a few bytes back, so we can overwrite some stuff.
entityFile:write(",\n\t", jsonSerialize.Marshal(rootEntity), "\n", "]")
end
rootEntity:AddTag("MapCaptured") -- Prevent recapturing.
end
end end
end)
if not ok then
print("Entity export error:", err)
end end
entityFile:flush() -- Ensure everything is written to disk before noita decides to crash. entityFile:flush() -- Ensure everything is written to disk before noita decides to crash.
end end
@ -105,15 +124,13 @@ local function captureScreenshot(x, y, rx, ry, entityFile)
end end
local function createOrOpenEntityCaptureFile() local function createOrOpenEntityCaptureFile()
-- Create or reopen entities CSV file. -- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.csv", "a+") local file = io.open("mods/noita-mapcap/output/entities.csv", "a")
if file == nil then return nil end if file ~= nil then file:close() end
if file:seek("end") == 0 then -- Create or reopen entities CSV file.
-- Empty file: Create header. file = io.open("mods/noita-mapcap/output/entities.csv", "r+b") -- Open for reading (r) and writing (+) in binary mode. r+b will not truncate the file to 0.
file:write("entityID, entityName, x, y, rotation, scaleX, scaleY, tags\n") if file == nil then return nil end
file:flush()
end
return file return file
end end

207
files/json-serialize.lua Normal file
View File

@ -0,0 +1,207 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Simple library to marshal JSON values.
---@type NoitaAPI
local noitaAPI = dofile_once("mods/noita-mapcap/files/noita-api.lua")
---@class JSONLib
local lib = {}
---Maps single characters to escaped strings.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
local escapeCharacters = {
["\\"] = "\\",
["\""] = "\"",
["\b"] = "b",
["\f"] = "f",
["\n"] = "n",
["\r"] = "r",
["\t"] = "t",
}
---escapeRune returns the escaped string for a given rune.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
---@param rune string
---@return string
local function escapeCharacter(rune)
return "\\" .. (escapeCharacters[rune] or string.format("u%04x", rune:byte()))
end
---escapeString returns the escaped version of the given string.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
---@param str string
---@return string
local function escapeString(str)
local result, count = str:gsub('[%z\1-\31\\"]', escapeCharacter)
return result
end
---MarshalString returns the JSON representation of a string value.
---@param val string
---@return string
function lib.MarshalString(val)
return string.format("%q", escapeString(val)) -- TODO: Escape strings correctly.
end
---MarshalNumber returns the JSON representation of a number value.
---@param val number
---@return string
function lib.MarshalNumber(val)
-- TODO: Marshal NaN, +Inf, -Inf, ... correctly
return tostring(val)
end
---MarshalBoolean returns the JSON representation of a boolean value.
---@param val number
---@return string
function lib.MarshalBoolean(val)
return tostring(val)
end
---MarshalObject returns the JSON representation of a table object.
---
---This only works with string keys. Number keys will be converted into strings.
---@param val table<string,any>
---@return string
function lib.MarshalObject(val)
local result = "{"
for k, v in pairs(val) do
result = result .. lib.MarshalString(k) .. ": " .. lib.Marshal(v)
-- Append character depending on whether this is the last element or not.
if next(val, k) == nil then
result = result .. "}"
else
result = result .. ", "
end
end
return result
end
---MarshalArray returns the JSON representation of an array object.
---
---@param val table<number,any>
---@param customMarshalFunction function|nil -- Custom function for marshalling the array values.
---@return string
function lib.MarshalArray(val, customMarshalFunction)
local result = "["
-- TODO: Check if the type of all array entries is the same.
local length = #val
for i, v in ipairs(val) do
if customMarshalFunction then
result = result .. customMarshalFunction(v)
else
result = result .. lib.Marshal(v)
end
-- Append character depending on whether this is the last element or not.
if i == length then
result = result .. "]"
else
result = result .. ", "
end
end
return result
end
---MarshalNoitaComponent returns the JSON representation of the given Noita component.
---@param component NoitaComponent
---@return string
function lib.MarshalNoitaComponent(component)
local resultObject = {
typeName = component:GetTypeName(),
members = component:GetMembers(),
--objectMembers = component:ObjectGetMembers
}
return lib.Marshal(resultObject)
end
---MarshalNoitaEntity returns the JSON representation of the given Noita entity.
---@param entity NoitaEntity
---@return string
function lib.MarshalNoitaEntity(entity)
local result = {
name = entity:GetName(),
filename = entity:GetFilename(),
tags = entity:GetTags(),
children = entity:GetAllChildren(),
components = entity:GetAllComponents(),
transform = {},
}
result.transform.x, result.transform.y, result.transform.rotation, result.transform.scaleX, result.transform.scaleY = entity:GetTransform()
return lib.Marshal(result)
end
---Marshal marshals any value into JSON representation.
---@param val any
---@return string
function lib.Marshal(val)
local t = type(val)
if t == "nil" then
return "null"
elseif t == "number" then
return lib.MarshalNumber(val)
elseif t == "string" then
return lib.MarshalString(val)
elseif t == "boolean" then
return lib.MarshalBoolean(val)
elseif t == "table" then
-- Check if object is instance of class...
if getmetatable(val) == noitaAPI.MetaTables.Component then
return lib.MarshalNoitaComponent(val)
elseif getmetatable(val) == noitaAPI.MetaTables.Entity then
return lib.MarshalNoitaEntity(val)
end
-- If not, fall back to array or object handling.
local commonKeyType, commonValueType
for k, v in pairs(val) do
local keyType, valueType = type(k), type(v)
commonKeyType = commonKeyType or keyType
if commonKeyType ~= keyType then
-- Different types detected, abort.
commonKeyType = "mixed"
break
end
commonValueType = commonValueType or valueType
if commonValueType ~= valueType then
-- Different types detected, abort.
commonValueType = "mixed"
break
end
end
-- Decide based on common types.
if commonKeyType == "number" and commonValueType ~= "mixed" then
return lib.MarshalArray(val) -- This will falsely detect sparse integer key maps as arrays. But meh.
elseif commonKeyType == "string" then
return lib.MarshalObject(val) -- This will not detect if there are number keys, which would work with MarshalObject.
elseif commonKeyType == nil and commonValueType == nil then
return "null" -- Fallback in case of empty table. There is no other way than using null, as we don't have type information without table elements.
end
error(string.format("unsupported table type. CommonKeyType = %s. CommonValueType = %s. MetaTable = %s", commonKeyType or "nil", commonValueType or "nil", getmetatable(val) or "nil"))
end
error(string.format("unsupported type %q", t))
end
return lib

473
files/noita-api.lua Normal file
View File

@ -0,0 +1,473 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Noita modding API, but a bit more beautiful.
-- Current modding API version: 7
-- State: Working but incomplete. If something is missing, add it by hand!
-- It would be optimal to generate this API wrapper automatically...
local EntityAPI = {}
---@class NoitaEntity
---@field ID integer -- Noita entity ID.
local NoitaEntity = {}
NoitaEntity.__index = NoitaEntity
local ComponentAPI = {}
---@class NoitaComponent
---@field ID integer -- Noita component ID.
local NoitaComponent = {}
NoitaComponent.__index = NoitaComponent
---
---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels.
---@return NoitaEntity|nil
function EntityAPI.Load(filename, posX, posY)
local entityID = EntityLoad(filename, posX, posY)
if entityID == nil then
return nil
end
return setmetatable({ ID = entityID }, NoitaEntity)
end
---
---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels.
---@return NoitaEntity|nil
function EntityAPI.LoadEndGameItem(filename, posX, posY)
local entityID = EntityLoadEndGameItem(filename, posX, posY)
if entityID == nil then
return nil
end
return setmetatable({ ID = entityID }, NoitaEntity)
end
---
---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels.
function EntityAPI.LoadCameraBound(filename, posX, posY)
return EntityLoadCameraBound(filename, posX, posY)
end
---
---@param filename string
---@param entity NoitaEntity
function EntityAPI.LoadToEntity(filename, entity)
return EntityLoadToEntity(filename, entity)
end
---
---Note: works only in dev builds.
---@param filename string
function NoitaEntity:Save(filename)
return EntitySave(self.ID, filename)
end
---
---@param name string
---@return NoitaEntity|nil
function EntityAPI.CreateNew(name)
local entityID = EntityCreateNew(name)
if entityID == nil then
return nil
end
return setmetatable({ ID = entityID }, NoitaEntity)
end
---
function NoitaEntity:Kill()
return EntityKill(self.ID)
end
---
function NoitaEntity:IsAlive()
return EntityGetIsAlive(self.ID)
end
---
---@param componentTypeName string
---@param tableOfComponentValues string[]|nil
---@return NoitaComponent|nil
function NoitaEntity:AddComponent(componentTypeName, tableOfComponentValues)
local componentID = EntityAddComponent(self.ID, componentTypeName, tableOfComponentValues)
if componentID == nil then
return nil
end
return setmetatable({ ID = componentID }, NoitaComponent)
end
---
---@param component NoitaComponent
function NoitaEntity:RemoveComponent(component)
return EntityRemoveComponent(self.ID, component.ID)
end
---Returns a table of with all components of this entity.
---@return NoitaComponent[]
function NoitaEntity:GetAllComponents()
local componentIDs = EntityGetAllComponents(self.ID) or {}
local result = {}
for _, componentID in ipairs(componentIDs) do
table.insert(result, setmetatable({ ID = componentID }, NoitaComponent))
end
return result
end
---Returns a table of components filtered by the given parameters.
---@param componentTypeName string
---@param tag string
---@return NoitaComponent[]
function NoitaEntity:GetComponents(componentTypeName, tag)
local componentIDs = EntityGetComponent(self.ID, componentTypeName, tag) or {}
local result = {}
for _, componentID in ipairs(componentIDs) do
table.insert(result, setmetatable({ ID = componentID }, NoitaComponent))
end
return result
end
---Returns the first component of this entity that fits the given parameters.
---@param componentTypeName string
---@param tag string
---@return NoitaComponent|nil
function NoitaEntity:GetFirstComponent(componentTypeName, tag)
local componentID = EntityGetFirstComponent(self.ID, componentTypeName, tag)
if componentID == nil then
return nil
end
return setmetatable({ ID = componentID }, NoitaComponent)
end
---Sets the transform of the entity.
---@param x number
---@param y number
---@param rotation number
---@param scaleX number
---@param scaleY number
function NoitaEntity:SetTransform(x, y, rotation, scaleX, scaleY)
return EntitySetTransform(self.ID, x, y, rotation, scaleX, scaleY)
end
---Sets the transform and tries to immediately refresh components that calculate values based on an entity's transform.
---@param x number
---@param y number
---@param rotation number
---@param scaleX number
---@param scaleY number
function NoitaEntity:SetAndApplyTransform(x, y, rotation, scaleX, scaleY)
return EntityApplyTransform(self.ID, x, y, rotation, scaleX, scaleY)
end
---Returns the transformation of the entity.
---@return number x, number y, number rotation, number scaleX, number scaleY
function NoitaEntity:GetTransform()
return EntityGetTransform(self.ID)
end
---
---@param child NoitaEntity
function NoitaEntity:AddChild(child)
return EntityAddChild(self.ID, child.ID)
end
---
---@return NoitaEntity[]
function NoitaEntity:GetAllChildren()
local entityIDs = EntityGetAllChildren(self.ID) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, setmetatable({ ID = entityID }, NoitaEntity))
end
return result
end
---
---@return NoitaEntity|nil
function NoitaEntity:GetParent()
local entityID = EntityGetParent(self.ID)
if entityID == nil then
return nil
end
return setmetatable({ ID = entityID }, NoitaEntity)
end
---Returns the given entity if it has no parent, otherwise walks up the parent hierarchy to the topmost parent and returns it.
---@return NoitaEntity
function NoitaEntity:GetRootEntity()
local entityID = EntityGetRootEntity(self.ID)
return setmetatable({ ID = entityID }, NoitaEntity)
end
---
function NoitaEntity:RemoveFromParent()
return EntityRemoveFromParent(self.ID)
end
---
---@param tag string
---@param enabled boolean
function NoitaEntity:SetComponentsWithTagEnabled(tag, enabled)
return EntitySetComponentsWithTagEnabled(self.ID, tag, enabled)
end
---
---@param component NoitaComponent
---@param enabled boolean
function NoitaEntity:SetComponentsEnabled(component, enabled)
return EntitySetComponentIsEnabled(self.ID, component.ID, enabled)
end
---
---@return string
function NoitaEntity:GetName()
return EntityGetName(self.ID)
end
---
---@param name string
function NoitaEntity:SetName(name)
return EntitySetName(self.ID, name)
end
---Returns an array of all the entity's tags.
---@return string[]
function NoitaEntity:GetTags()
---@type string
local tagsString = EntityGetTags(self.ID)
local result = {}
for tag in tagsString:gmatch('([^,]+)') do
table.insert(result, tag)
end
return result
end
---Returns all entities with 'tag'.
---@param tag string
---@return NoitaEntity[]
function EntityAPI.GetWithTag(tag)
local entityIDs = EntityGetWithTag(tag) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, setmetatable({ ID = entityID }, NoitaEntity))
end
return result
end
---Returns all entities in 'radius' distance from 'x','y'.
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels.
---@param radius number -- Radius in world (virtual) pixels.
---@return NoitaEntity[]
function EntityAPI.GetInRadius(posX, posY, radius)
local entityIDs = EntityGetInRadius(posX, posY, radius) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, setmetatable({ ID = entityID }, NoitaEntity))
end
return result
end
---Returns all entities in 'radius' distance from 'x','y' that have the given tag.
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels.
---@param radius number -- Radius in world (virtual) pixels.
---@param tag string
---@return NoitaEntity[]
function EntityAPI.GetInRadiusWithTag(posX, posY, radius, tag)
local entityIDs = EntityGetInRadiusWithTag(posX, posY, radius, tag) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, setmetatable({ ID = entityID }, NoitaEntity))
end
return result
end
---
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels.
---@return NoitaEntity|nil
function EntityAPI.GetClosest(posX, posY)
local entityID = EntityGetClosest(posX, posY)
if entityID == nil then
return nil
end
return setmetatable({ ID = entityID }, NoitaEntity)
end
---
---@param name string
---@return NoitaEntity|nil
function EntityAPI.GetWithName(name)
local entityID = EntityGetWithName(name)
if entityID == nil then
return nil
end
return setmetatable({ ID = entityID }, NoitaEntity)
end
---
---@param tag string
function NoitaEntity:AddTag(tag)
return EntityAddTag(self.ID, tag)
end
---
---@param tag string
function NoitaEntity:RemoveTag(tag)
return EntityRemoveTag(self.ID, tag)
end
---
---@param tag string
---@return boolean
function NoitaEntity:HasTag(tag)
return EntityHasTag(self.ID, tag)
end
---
---@return string -- example: 'data/entities/items/flute.xml'.
function NoitaEntity:GetFilename()
return EntityGetFilename(self.ID)
end
---
---@param tag string
function NoitaComponent:AddTag(tag)
return ComponentAddTag(self.ID, tag)
end
---
---@param tag string
function NoitaComponent:RemoveTag(tag)
return ComponentRemoveTag(self.ID, tag)
end
---
---@param tag string
---@return boolean
function NoitaComponent:HasTag(tag)
return ComponentHasTag(self.ID, tag)
end
---Returns one or many values matching the type or subtypes of the requested field.
---Reports error and returns nil if the field type is not supported or field was not found.
---@param fieldName string
---@return any|nil
function NoitaComponent:GetValue(fieldName)
return ComponentGetValue2(self.ID, fieldName)
end
---Sets the value of a field. Value(s) should have a type matching the field type.
---Reports error if the values weren't given in correct type, the field type is not supported, or the component does not exist.
---@param fieldName string
---@param valueOrValues any|nil
function NoitaComponent:SetValue(fieldName, valueOrValues)
return ComponentSetValue2(self.ID, fieldName, valueOrValues)
end
---Returns one or many values matching the type or subtypes of the requested field in a component subobject.
---Reports error and returns nil if the field type is not supported or 'object_name' is not a metaobject.
---@param objectName string
---@param fieldName string
---@return any
function NoitaComponent:ObjectGetValue(objectName, fieldName)
return ComponentObjectGetValue2(self.ID, objectName, fieldName)
end
---Sets the value of a field in a component subobject. Value(s) should have a type matching the field type.
---Reports error if the values weren't given in correct type, the field type is not supported or 'object_name' is not a metaobject.
---@param objectName string
---@param fieldName string
---@param valueOrValues any
function NoitaComponent:ObjectSetValue(objectName, fieldName, valueOrValues)
return ComponentObjectSetValue2(self.ID, objectName, fieldName, valueOrValues)
end
---Creates a component of type 'component_type_name' and adds it to 'entity_id'.
---'table_of_component_values' should be a string-indexed table, where keys are field names and values are field values of correct type.
---The value setting works like ComponentObjectSetValue2(), with the exception that multivalue types are not supported.
---Additional supported values are _tags:comma_separated_string and _enabled:bool, which basically work like the those fields work in entity XML files.
---Returns the created component, if creation succeeded, or nil.
---@param componentTypeName string
---@param tableOfComponentValues table<string, any>
---@return NoitaComponent|nil
function NoitaEntity:EntityAddComponent(componentTypeName, tableOfComponentValues)
local componentID = EntityAddComponent2(self.ID, componentTypeName, tableOfComponentValues)
if componentID == nil then
return nil
end
return setmetatable({ ID = componentID }, NoitaComponent)
end
---'type_stored_in_vector' should be "int", "float" or "string".
---@param arrayMemberName string
---@param typeStoredInVector string
---@return number
function NoitaComponent:GetVectorSize(arrayMemberName, typeStoredInVector)
return ComponentGetVectorSize(self.ID, arrayMemberName, typeStoredInVector)
end
---'type_stored_in_vector' should be "int", "float" or "string".
---@param arrayName string
---@param typeStoredInVector string
---@param index number
---@return number|number|string|nil
function NoitaComponent:GetVectorValue(arrayName, typeStoredInVector, index)
return ComponentGetVectorValue(self.ID, arrayName, typeStoredInVector, index)
end
---'type_stored_in_vector' should be "int", "float" or "string".
---@param arrayName string
---@param typeStoredInVector string
---@return number[]|number|string|nil
function NoitaComponent:GetVector(arrayName, typeStoredInVector)
return ComponentGetVector(self.ID, arrayName, typeStoredInVector)
end
---Returns true if the given component exists and is enabled, else false.
---@return boolean
function NoitaComponent:GetIsEnabled()
return ComponentGetIsEnabled(self.ID)
end
---Returns a string-indexed table of string.
---@return table<string, string>|nil
function NoitaComponent:GetMembers()
return ComponentGetMembers(self.ID)
end
---Returns a string-indexed table of string or nil.
---@param objectName string
---@return table<string, string>|nil
function NoitaComponent:ObjectGetMembers(objectName)
return ComponentObjectGetMembers(self.ID, objectName)
end
---@return string string
function NoitaComponent:GetTypeName()
return ComponentGetTypeName(self.ID)
end
-- TODO: Add missing Noita API methods and functions.
---@class NoitaAPI
local api = {
Component = ComponentAPI,
Entity = EntityAPI,
MetaTables = {
Component = NoitaComponent,
Entity = NoitaEntity,
},
}
return api