noita-mapcap/files/capture.lua

313 lines
12 KiB
Lua
Raw Normal View History

-- Copyright (c) 2019-2022 David Vogel
--
-- 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 json = 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.
-- "Base layout" (Base layout. Every part outside this is based on a similar layout, but uses different materials/seeds)
CAPTURE_AREA_BASE_LAYOUT = {
Left = -17920, -- in virtual (world) pixels.
Top = -7168, -- in virtual (world) pixels.
Right = 17920, -- in virtual (world) pixels. (Coordinate is not included in the rectangle)
Bottom = 17408 -- in virtual (world) pixels. (Coordinate is not included in the rectangle)
}
-- "Main world" (The main world with 3 parts: sky, normal and hell)
CAPTURE_AREA_MAIN_WORLD = {
Left = -17920, -- in virtual (world) pixels.
Top = -31744, -- in virtual (world) pixels.
Right = 17920, -- in virtual (world) pixels. (Coordinate is not included in the rectangle)
Bottom = 41984 -- in virtual (world) pixels. (Coordinate is not included in the rectangle)
}
-- "Extended" (Main world + a fraction of the parallel worlds to the left and right)
CAPTURE_AREA_EXTENDED = {
Left = -25600, -- in virtual (world) pixels.
Top = -31744, -- in virtual (world) pixels.
Right = 25600, -- in virtual (world) pixels. (Coordinate is not included in the rectangle)
Bottom = 41984 -- in virtual (world) pixels. (Coordinate is not included in the rectangle)
}
local componentTypeNamesToDisable = {
"AnimalAIComponent",
"SimplePhysicsComponent",
"CharacterPlatformingComponent",
"WormComponent",
"WormAIComponent",
"DamageModelComponent",
"PhysicsBodyCollisionDamageComponent",
"ExplodeOnDamageComponent",
--"SpriteOffsetAnimatorComponent",
--"PhysicsBody2Component", -- Disabling will hide barrels and similar stuff, also triggers an assertion.
--"PhysicsBodyComponent",
--"VelocityComponent", -- Disabling this component may cause a "...\component_updators\advancedfishai_system.cpp at line 107" exception.
--"SpriteComponent",
--"AudioComponent",
}
---
---@return file*|nil
local function createOrOpenEntityCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.json", "a")
if file ~= nil then file:close() end
-- Create or reopen entities CSV file.
file = io.open("mods/noita-mapcap/output/entities.json", "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
---captureEntities gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and modifies those entities.
---@param entityFile file*|nil
---@param x number
---@param y number
---@param radius number
local function captureEntities(entityFile, x, y, radius)
if not entityFile then return end
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", json.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", json.Marshal(rootEntity), "\n", "]")
end
-- Prevent recapturing.
rootEntity:AddTag("MapCaptured")
-- Disable some components.
for _, componentTypeName in ipairs(componentTypeNamesToDisable) do
local components = rootEntity:GetComponents(componentTypeName)
for _, component in ipairs(components) do
rootEntity:SetComponentsEnabled(component, false)
end
end
-- Modify the gravity of every VelocityComponent, so stuff will not fall.
local component = rootEntity:GetFirstComponent("VelocityComponent")
if component then
component:SetValue("gravity_x", 0)
component:SetValue("gravity_y", 0)
end
-- Modify the gravity of every CharacterPlatformingComponent, so mobs will not fall.
local component = rootEntity:GetFirstComponent("CharacterPlatformingComponent")
if component then
component:SetValue("pixel_gravity", 0)
end
-- Disable the hover and spinning animations of every ItemComponent.
local component = rootEntity:GetFirstComponent("ItemComponent")
if component then
component:SetValue("play_hover_animation", false)
component:SetValue("play_spinning_animation", false)
end
-- Disable the hover animation of cards. Disabling the "SpriteOffsetAnimatorComponent" does not help.
--local components = rootEntity:GetComponents("SpriteOffsetAnimatorComponent")
--for _, component in ipairs(components) do
-- component:SetValue("x_speed", 0)
-- component:SetValue("y_speed", 0)
-- component:SetValue("x_amount", 0)
-- component:SetValue("y_amount", 0)
--end
end
end
-- Ensure everything is written to disk before noita decides to crash.
entityFile:flush()
end
--- Captures a screenshot at the given coordinates.
--- This will block until all chunks in the given area are loaded.
---
--- @param x number -- Virtual x coordinate (World pixels) of the screen center.
--- @param y number -- Virtual y coordinate (World pixels) of the screen center.
--- @param rx number -- Screen x coordinate of the top left corner of the screenshot rectangle.
--- @param ry number -- Screen y coordinate of the top left corner of the screenshot rectangle.
--- @param entityFile file*
local function captureScreenshot(x, y, rx, ry, entityFile)
local virtualWidth, virtualHeight =
tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_X")),
tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_Y"))
local virtualHalfWidth, virtualHalfHeight = math.floor(virtualWidth / 2), math.floor(virtualHeight / 2)
local xMin, yMin = x - virtualHalfWidth, y - virtualHalfHeight
local xMax, yMax = xMin + virtualWidth, yMin + virtualHeight
UiCaptureDelay = 0
GameSetCameraPos(x, y)
repeat
if UiCaptureDelay > 100 then
-- Wiggle the screen a bit, as chunks sometimes don't want to load.
GameSetCameraPos(x + math.random(-100, 100), y + math.random(-100, 100))
DrawUI()
wait(0)
UiCaptureDelay = UiCaptureDelay + 1
GameSetCameraPos(x, y)
end
DrawUI()
wait(0)
UiCaptureDelay = UiCaptureDelay + 1
-- Capture all entities right after the camera frame was moved.
local ok, err = pcall(captureEntities, entityFile, x, y, 5000)
if not ok then
print(string.format("Entity capture error: %s", err))
end
until DoesWorldExistAt(xMin, yMin, xMax, yMax) -- Chunks will be drawn on the *next* frame.
wait(0) -- Without this line empty chunks may still appear, also it's needed for the UI to disappear.
if not TriggerCapture(rx, ry) then
UiCaptureProblem = "Screen capture failed. Please restart Noita."
end
-- Reset monitor and PC standby each screenshot.
ResetStandbyTimer()
end
function startCapturingSpiral()
local entityFile = createOrOpenEntityCaptureFile()
local ox, oy = GameGetCameraPos() -- Returns the virtual coordinates of the screen center.
ox, oy = math.floor(ox / CAPTURE_GRID_SIZE) * CAPTURE_GRID_SIZE, math.floor(oy / CAPTURE_GRID_SIZE) * CAPTURE_GRID_SIZE
ox, oy = ox + 256, oy + 256 -- Align screen with ingame chunk grid that is 512x512.
local x, y = ox, oy
local virtualWidth, virtualHeight =
tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_X")),
tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_Y"))
local virtualHalfWidth, virtualHalfHeight = math.floor(virtualWidth / 2), math.floor(virtualHeight / 2)
GameSetCameraFree(true)
-- Coroutine to calculate next coordinate, and trigger screenshots.
local i = 1
async_loop(
function()
-- +x
for i = 1, i, 1 do
local rx, ry = (x - virtualHalfWidth) * CAPTURE_PIXEL_SIZE, (y - virtualHalfHeight) * CAPTURE_PIXEL_SIZE
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then
captureScreenshot(x, y, rx, ry, entityFile)
end
x, y = x + CAPTURE_GRID_SIZE, y
end
-- +y
for i = 1, i, 1 do
local rx, ry = (x - virtualHalfWidth) * CAPTURE_PIXEL_SIZE, (y - virtualHalfHeight) * CAPTURE_PIXEL_SIZE
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then
captureScreenshot(x, y, rx, ry, entityFile)
end
x, y = x, y + CAPTURE_GRID_SIZE
end
i = i + 1
-- -x
for i = 1, i, 1 do
local rx, ry = (x - virtualHalfWidth) * CAPTURE_PIXEL_SIZE, (y - virtualHalfHeight) * CAPTURE_PIXEL_SIZE
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then
captureScreenshot(x, y, rx, ry, entityFile)
end
x, y = x - CAPTURE_GRID_SIZE, y
end
-- -y
for i = 1, i, 1 do
local rx, ry = (x - virtualHalfWidth) * CAPTURE_PIXEL_SIZE, (y - virtualHalfHeight) * CAPTURE_PIXEL_SIZE
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then
captureScreenshot(x, y, rx, ry, entityFile)
end
x, y = x, y - CAPTURE_GRID_SIZE
end
i = i + 1
end
)
end
function startCapturingHilbert(area)
local entityFile = createOrOpenEntityCaptureFile()
local ox, oy = GameGetCameraPos()
local virtualWidth, virtualHeight =
tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_X")),
tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_Y"))
local virtualHalfWidth, virtualHalfHeight = math.floor(virtualWidth / 2), math.floor(virtualHeight / 2)
-- Get size of the rectangle in grid/chunk coordinates.
local gridLeft = math.floor(area.Left / CAPTURE_GRID_SIZE)
local gridTop = math.floor(area.Top / CAPTURE_GRID_SIZE)
local gridRight = math.ceil(area.Right / CAPTURE_GRID_SIZE) -- This grid coordinate is not included.
local gridBottom = math.ceil(area.Bottom / CAPTURE_GRID_SIZE) -- This grid coordinate is not included.
-- Edge case
if area.Left == area.Right then
gridRight = gridLeft
end
if area.Top == area.Bottom then
gridBottom = gridTop
end
-- Size of the grid in chunks.
local gridWidth = gridRight - gridLeft
local gridHeight = gridBottom - gridTop
-- Hilbert curve can only fit into a square, so get the longest side.
local gridPOTSize = math.ceil(math.log(math.max(gridWidth, gridHeight)) / math.log(2))
-- Max size (Already rounded up to the next power of two).
local gridMaxSize = math.pow(2, gridPOTSize)
local t, tLimit = 0, gridMaxSize * gridMaxSize
UiProgress = {Progress = 0, Max = gridWidth * gridHeight}
GameSetCameraFree(true)
-- Coroutine to calculate next coordinate, and trigger screenshots.
async(
function()
while t < tLimit do
local hx, hy = mapHilbert(t, gridPOTSize)
if hx < gridWidth and hy < gridHeight then
local x, y = (hx + gridLeft) * CAPTURE_GRID_SIZE, (hy + gridTop) * CAPTURE_GRID_SIZE
x, y = x + 256, y + 256 -- Align screen with ingame chunk grid that is 512x512.
local rx, ry = (x - virtualHalfWidth) * CAPTURE_PIXEL_SIZE, (y - virtualHalfHeight) * CAPTURE_PIXEL_SIZE
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then
captureScreenshot(x, y, rx, ry, entityFile)
end
UiProgress.Progress = UiProgress.Progress + 1
end
t = t + 1
end
UiProgress.Done = true
end
)
end