diff --git a/files/capture.lua b/files/capture.lua index 50b379f..2b2fcff 100644 --- a/files/capture.lua +++ b/files/capture.lua @@ -3,6 +3,12 @@ -- This software is released under the MIT License. -- 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_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 @@ -84,18 +90,31 @@ local function captureScreenshot(x, y, rx, ry, entityFile) -- Capture entities right after capturing the screenshot. if entityFile then - local radius = math.sqrt(virtualHalfWidth^2 + virtualHalfHeight^2) + 1 - local entities = EntityGetInRadius(x, y, radius) - for _, entityID in ipairs(entities) do - -- Make sure to only export entities when they are encountered the first time. - if not EntityHasTag(entityID, "MapCaptured") then - local x, y, rotation, scaleX, scaleY = EntityGetTransform(entityID) - local entityName = EntityGetName(entityID) - local entityTags = EntityGetTags(entityID) - entityFile:write(string.format("%d, %s, %f, %f, %f, %f, %f, %q\n", entityID, entityName, x, y, rotation, scaleX, scaleY, entityTags)) - -- TODO: Correctly escape CSV data - EntityAddTag(entityID, "MapCaptured") -- Prevent recapturing. + local ok, err = pcall(function() + local radius = math.sqrt(virtualHalfWidth^2 + virtualHalfHeight^2) + 1 + local entities = noitaAPI.Entity.GetInRadius(x, y, radius) + for _, entity in ipairs(entities) do + -- Get to the root entity, as we are exporting entire entity trees. + local rootEntity = entity:GetRootEntity() + -- Make sure to only export entities when they are encountered the first time. + if not rootEntity:HasTag("MapCaptured") then + -- Some hacky way to generate valid JSON that doesn't break when the game crashes. + -- Well, as long as it does not crash between write and flush. + 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) + if not ok then + print("Entity export error:", err) end entityFile:flush() -- Ensure everything is written to disk before noita decides to crash. end @@ -105,15 +124,13 @@ local function captureScreenshot(x, y, rx, ry, entityFile) end local function createOrOpenEntityCaptureFile() - -- Create or reopen entities CSV file. - local file = io.open("mods/noita-mapcap/output/entities.csv", "a+") - if file == nil then return nil end + -- Make sure the file exists. + local file = io.open("mods/noita-mapcap/output/entities.csv", "a") + if file ~= nil then file:close() end - if file:seek("end") == 0 then - -- Empty file: Create header. - file:write("entityID, entityName, x, y, rotation, scaleX, scaleY, tags\n") - file:flush() - end + -- Create or reopen entities CSV file. + 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. + if file == nil then return nil end return file end diff --git a/files/json-serialize.lua b/files/json-serialize.lua new file mode 100644 index 0000000..a527c37 --- /dev/null +++ b/files/json-serialize.lua @@ -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 +---@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 +---@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 diff --git a/files/noita-api.lua b/files/noita-api.lua new file mode 100644 index 0000000..1daa2a8 --- /dev/null +++ b/files/noita-api.lua @@ -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 +---@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|nil +function NoitaComponent:GetMembers() + return ComponentGetMembers(self.ID) +end + +---Returns a string-indexed table of string or nil. +---@param objectName string +---@return table|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