2022-07-17 12:39:18 +00:00
-- Copyright (c) 2019-2022 David Vogel
2019-10-18 20:35:51 +00:00
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
2022-07-23 18:43:04 +00:00
--------------------------
-- Load library modules --
--------------------------
2022-07-24 20:05:34 +00:00
local CameraAPI = require ( " noita-api.camera " )
local Coords = require ( " coordinates " )
2022-07-23 18:43:04 +00:00
local EntityAPI = require ( " noita-api.entity " )
local Hilbert = require ( " hilbert-curve " )
2022-07-24 20:05:34 +00:00
local JSON = require ( " noita-api.json " )
2022-07-26 22:06:09 +00:00
local MonitorStandby = require ( " monitor-standby " )
local ProcessRunner = require ( " process-runner " )
2022-07-24 20:05:34 +00:00
local ScreenCapture = require ( " screen-capture " )
2022-07-26 22:06:09 +00:00
local Utils = require ( " noita-api.utils " )
2022-07-28 12:18:01 +00:00
local Vec2 = require ( " noita-api.vec2 " )
2022-07-26 22:06:09 +00:00
------------------
-- Global stuff --
------------------
2022-07-23 15:36:21 +00:00
----------
-- Code --
----------
2022-07-17 23:32:44 +00:00
2022-07-26 22:06:09 +00:00
Capture.MapCapturingCtx = Capture.MapCapturingCtx or ProcessRunner.New ( )
Capture.EntityCapturingCtx = Capture.EntityCapturingCtx or ProcessRunner.New ( )
2022-08-10 18:41:57 +00:00
Capture.PlayerPathCapturingCtx = Capture.PlayerPathCapturingCtx or ProcessRunner.New ( )
2022-07-18 20:07:53 +00:00
2022-07-26 22:06:09 +00:00
---Returns a capturing rectangle in window coordinates, and also the world coordinates for the same rectangle.
---The rectangle is sized and placed in a way that aligns as pixel perfect as possible with the world coordinates.
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically.
---@return Vec2 topLeftCapture
---@return Vec2 bottomRightCapture
---@return Vec2 topLeftWorld
---@return Vec2 bottomRightWorld
local function calculateCaptureRectangle ( pos )
local topLeft , bottomRight = Coords : ValidRenderingRect ( )
-- Convert valid rendering rectangle into world coordinates, and round it towards the window center.
local topLeftWorld , bottomRightWorld = Coords : ToWorld ( topLeft , pos ) : Rounded ( " ceil " ) , Coords : ToWorld ( bottomRight , pos ) : Rounded ( " floor " )
-- Convert back into window coordinates, and round to nearest.
local topLeftCapture , bottomRightCapture = Coords : ToWindow ( topLeftWorld , pos ) : Rounded ( ) , Coords : ToWindow ( bottomRightWorld , pos ) : Rounded ( )
return topLeftCapture , bottomRightCapture , topLeftWorld , bottomRightWorld
end
---Captures a screenshot at the given position in world coordinates.
---This will block until all chunks in the virtual rectangle are loaded.
2022-07-18 20:07:53 +00:00
---
2022-07-26 22:06:09 +00:00
---Don't set `ensureLoaded` to true when `pos` is nil!
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
---@param ensureLoaded boolean|nil -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
---@param dontOverwrite boolean|nil -- If true, the function will abort if there is already a file with the same coordinates.
---@param ctx ProcessRunnerCtx|nil -- The process runner context this runs in.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
local function captureScreenshot ( pos , ensureLoaded , dontOverwrite , ctx , outputPixelScale )
2022-08-11 08:56:24 +00:00
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords : PixelScale ( )
end
2019-10-18 20:35:51 +00:00
2022-07-29 20:48:42 +00:00
local rectTopLeft , rectBottomRight = ScreenCapture.GetRect ( )
if Coords.WindowResolution ~= rectBottomRight - rectTopLeft then
error ( string.format ( " window size seems to have changed from %s to %s " , Coords.WindowResolution , rectBottomRight - rectTopLeft ) )
end
2022-07-26 22:06:09 +00:00
local topLeftCapture , bottomRightCapture , topLeftWorld , bottomRightWorld = calculateCaptureRectangle ( pos )
2022-07-18 20:07:53 +00:00
2022-07-26 22:06:09 +00:00
---Top left in output coordinates.
---@type Vec2
2022-08-11 08:56:24 +00:00
local outputTopLeft = ( topLeftWorld * outputPixelScale ) : Rounded ( )
2022-07-26 22:06:09 +00:00
-- Check if the file exists, and if we are allowed to overwrite it.
if dontOverwrite and Utils.FileExists ( string.format ( " mods/noita-mapcap/output/%d,%d.png " , outputTopLeft.x , outputTopLeft.y ) ) then
return
end
if pos then CameraAPI.SetPos ( pos ) end
if ensureLoaded then
local delayFrames = 0
2022-07-29 17:42:44 +00:00
if ctx then ctx.state . WaitFrames = delayFrames end
2022-07-26 22:06:09 +00:00
repeat
-- Prematurely stop capturing if that is requested by the context.
if ctx and ctx : IsStopping ( ) then return end
2022-07-27 23:48:49 +00:00
2022-07-28 21:05:25 +00:00
if delayFrames > 30 then
2022-07-26 22:06:09 +00:00
-- Wiggle the screen a bit, as chunks sometimes don't want to load.
2022-07-28 21:05:25 +00:00
if pos then CameraAPI.SetPos ( pos + Vec2 ( math.random ( - 10 , 10 ) , math.random ( - 10 , 10 ) ) ) end
2022-07-26 22:06:09 +00:00
wait ( 0 )
delayFrames = delayFrames + 1
2022-07-29 17:42:44 +00:00
if ctx then ctx.state . WaitFrames = delayFrames end
2022-07-26 22:06:09 +00:00
if pos then CameraAPI.SetPos ( pos ) end
end
wait ( 0 )
delayFrames = delayFrames + 1
2022-07-29 17:42:44 +00:00
if ctx then ctx.state . WaitFrames = delayFrames end
2022-07-26 22:06:09 +00:00
2022-07-28 12:18:01 +00:00
local topLeftBounds , bottomRightBounds = CameraAPI : Bounds ( )
until DoesWorldExistAt ( topLeftBounds.x , topLeftBounds.y , bottomRightBounds.x , bottomRightBounds.y )
2022-07-26 22:06:09 +00:00
-- Chunks are loaded and will be drawn on the *next* frame.
end
-- Suspend UI drawing for 1 frame.
2022-07-27 23:48:49 +00:00
UI : SuspendDrawing ( 1 )
2022-07-26 22:06:09 +00:00
wait ( 0 )
-- Fetch coordinates again, as they may have changed.
if not pos then
topLeftCapture , bottomRightCapture , topLeftWorld , bottomRightWorld = calculateCaptureRectangle ( pos )
2022-07-27 23:48:49 +00:00
if outputPixelScale > 0 then
outputTopLeft = ( topLeftWorld * outputPixelScale ) : Rounded ( )
else
outputTopLeft = topLeftWorld
end
2022-07-26 22:06:09 +00:00
end
-- The top left world position needs to be upscaled by the pixel scale.
-- Otherwise it's not possible to stitch the images correctly.
if not ScreenCapture.Capture ( topLeftCapture , bottomRightCapture , outputTopLeft , ( bottomRightWorld - topLeftWorld ) * outputPixelScale ) then
error ( string.format ( " failed to capture screenshot " ) )
end
-- Reset monitor and PC standby every screenshot.
MonitorStandby.ResetTimer ( )
end
---Map capture process runner context error handler callback. Just rolls off the tongue.
---@param err string
---@param scope "init"|"do"|"end"
local function mapCapturingCtxErrHandler ( err , scope )
2022-07-29 20:48:42 +00:00
print ( string.format ( " Failed to capture map: %s. " , err ) )
2022-07-27 23:48:49 +00:00
Message : ShowRuntimeError ( " MapCaptureError " , " Failed to capture map: " , tostring ( err ) )
2022-07-26 22:06:09 +00:00
end
---Starts the capturing process in a spiral around origin.
---Use `Capture.MapCapturingCtx` to stop, control or view the progress.
---@param origin Vec2 -- Center of the spiral in world pixels.
---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture : StartCapturingSpiral ( origin , captureGridSize , outputPixelScale )
2022-07-27 23:48:49 +00:00
-- Create file that signals that there are files in the output directory.
local file = io.open ( " mods/noita-mapcap/output/nonempty " , " a " )
if file ~= nil then file : close ( ) end
2022-07-26 22:06:09 +00:00
---Origin rounded to capture grid.
---@type Vec2
2022-07-28 10:06:47 +00:00
local origin = ( origin / captureGridSize ) : Rounded ( " floor " ) * captureGridSize
2022-07-26 22:06:09 +00:00
---The position in world coordinates.
2022-07-29 20:48:42 +00:00
---Centered to the grid.
2022-07-26 22:06:09 +00:00
---@type Vec2
2022-08-10 18:41:57 +00:00
local pos = origin + Vec2 ( captureGridSize / 2 , captureGridSize / 2 )
2022-07-26 22:06:09 +00:00
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo ( ctx )
2022-07-28 11:27:02 +00:00
Modification.SetCameraFree ( true )
2022-07-26 22:06:09 +00:00
local i = 1
repeat
-- +x
for _ = 1 , i , 1 do
captureScreenshot ( pos , true , true , ctx , outputPixelScale )
pos : Add ( Vec2 ( captureGridSize , 0 ) )
end
-- +y
for _ = 1 , i , 1 do
captureScreenshot ( pos , true , true , ctx , outputPixelScale )
pos : Add ( Vec2 ( 0 , captureGridSize ) )
end
i = i + 1
-- -x
for _ = 1 , i , 1 do
captureScreenshot ( pos , true , true , ctx , outputPixelScale )
pos : Add ( Vec2 ( - captureGridSize , 0 ) )
end
-- -y
for _ = 1 , i , 1 do
captureScreenshot ( pos , true , true , ctx , outputPixelScale )
pos : Add ( Vec2 ( 0 , - captureGridSize ) )
end
i = i + 1
until ctx : IsStopping ( )
end
2022-07-28 11:27:02 +00:00
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd ( ctx )
Modification.SetCameraFree ( )
end
2022-07-26 22:06:09 +00:00
-- Run process, if there is no other running right now.
2022-07-28 11:27:02 +00:00
self.MapCapturingCtx : Run ( nil , handleDo , handleEnd , mapCapturingCtxErrHandler )
2022-07-26 22:06:09 +00:00
end
---Starts the capturing process of the given area.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param topLeft Vec2 -- Top left of the to be captured rectangle.
---@param bottomRight Vec2 -- Non included bottom left of the to be captured rectangle.
---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture : StartCapturingArea ( topLeft , bottomRight , captureGridSize , outputPixelScale )
2022-07-27 23:48:49 +00:00
-- Create file that signals that there are files in the output directory.
local file = io.open ( " mods/noita-mapcap/output/nonempty " , " a " )
if file ~= nil then file : close ( ) end
2022-07-26 22:06:09 +00:00
---The rectangle in grid coordinates.
---@type Vec2, Vec2
local gridTopLeft , gridBottomRight = ( topLeft / captureGridSize ) : Rounded ( " floor " ) , ( bottomRight / captureGridSize ) : Rounded ( " floor " )
-- Handle edge cases.
if topLeft.x == bottomRight.x then gridBottomRight.x = gridTopLeft.x end
if topLeft.y == bottomRight.y then gridBottomRight.y = gridTopLeft.y end
---Size of the rectangle in grid coordinates.
---@type Vec2
local gridSize = gridBottomRight - gridTopLeft
-- Hilbert curve can only fit into a square, so get the longest side.
local gridPOTSize = math.ceil ( math.log ( math.max ( gridSize.x , gridSize.y ) ) / 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
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo ( ctx )
2022-07-28 11:27:02 +00:00
Modification.SetCameraFree ( true )
2022-08-10 18:41:57 +00:00
ctx.state = { Current = 0 , Max = gridSize.x * gridSize.y }
2022-07-26 22:06:09 +00:00
while t < tLimit do
-- Prematurely stop capturing if that is requested by the context.
if ctx : IsStopping ( ) then return end
---Position in grid coordinates.
---@type Vec2
local hilbertPos = Vec2 ( Hilbert.Map ( t , gridPOTSize ) )
if hilbertPos.x < gridSize.x and hilbertPos.y < gridSize.y then
---Position in world coordinates.
---@type Vec2
local pos = ( hilbertPos + gridTopLeft ) * captureGridSize
2022-08-10 18:41:57 +00:00
pos : Add ( Vec2 ( captureGridSize / 2 , captureGridSize / 2 ) ) -- Move to center of grid cell.
2022-07-26 22:06:09 +00:00
captureScreenshot ( pos , true , true , ctx , outputPixelScale )
2022-07-29 17:42:44 +00:00
ctx.state . Current = ctx.state . Current + 1
2022-07-26 22:06:09 +00:00
end
t = t + 1
end
end
2022-07-28 11:27:02 +00:00
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd ( ctx )
Modification.SetCameraFree ( )
end
2022-07-26 22:06:09 +00:00
-- Run process, if there is no other running right now.
2022-07-28 11:27:02 +00:00
self.MapCapturingCtx : Run ( nil , handleDo , handleEnd , mapCapturingCtxErrHandler )
2022-07-18 20:07:53 +00:00
end
2022-07-26 22:06:09 +00:00
---Starts the live capturing process.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
2022-07-28 12:01:44 +00:00
function Capture : StartCapturingLive ( outputPixelScale )
---Queries the mod settings for the live capture parameters.
---@return integer interval -- The interval length in frames. Defaults to 30.
---@return number minDistanceSqr -- The minimum (squared) distance between screenshots. This will prevent screenshots if the player doesn't move much.
---@return number maxDistanceSqr -- The maximum (squared) distance between screenshots. This will allow more screenshots per interval if the player moves fast.
local function querySettings ( )
local interval = tonumber ( ModSettingGet ( " noita-mapcap.live-interval " ) ) or 30
local minDistance = tonumber ( ModSettingGet ( " noita-mapcap.live-min-distance " ) ) or 10
local maxDistance = tonumber ( ModSettingGet ( " noita-mapcap.live-max-distance " ) ) or 50
return interval , minDistance ^ 2 , maxDistance ^ 2
end
2022-07-26 22:06:09 +00:00
2022-07-27 23:48:49 +00:00
-- Create file that signals that there are files in the output directory.
local file = io.open ( " mods/noita-mapcap/output/nonempty " , " a " )
if file ~= nil then file : close ( ) end
2022-07-26 22:06:09 +00:00
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo ( ctx )
2022-07-28 11:27:02 +00:00
Modification.SetCameraFree ( false )
2022-07-26 22:06:09 +00:00
local oldPos
repeat
2022-07-28 12:01:44 +00:00
local interval , minDistanceSqr , maxDistanceSqr = querySettings ( )
2022-07-26 22:06:09 +00:00
-- Wait until we are allowed to take a new screenshot.
local delayFrames = 0
repeat
wait ( 0 )
delayFrames = delayFrames + 1
local distanceSqr
if oldPos then distanceSqr = CameraAPI.GetPos ( ) : DistanceSqr ( oldPos ) else distanceSqr = math.huge end
until ctx : IsStopping ( ) or ( ( delayFrames >= interval or distanceSqr >= maxDistanceSqr ) and distanceSqr >= minDistanceSqr )
captureScreenshot ( nil , false , false , ctx , outputPixelScale )
oldPos = CameraAPI.GetPos ( )
until ctx : IsStopping ( )
end
2022-07-28 11:27:02 +00:00
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd ( ctx )
Modification.SetCameraFree ( )
end
2022-07-26 22:06:09 +00:00
-- Run process, if there is no other running right now.
2022-07-28 11:27:02 +00:00
self.MapCapturingCtx : Run ( nil , handleDo , handleEnd , mapCapturingCtxErrHandler )
2022-07-26 22:06:09 +00:00
end
---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and modifies those entities.
---@param file file*|nil
---@param modify boolean
2022-07-18 20:07:53 +00:00
---@param x number
---@param y number
---@param radius number
2022-07-26 22:06:09 +00:00
local function captureModifyEntities ( file , modify , x , y , radius )
2022-07-22 19:31:40 +00:00
local entities = EntityAPI.GetInRadius ( x , y , radius )
2022-07-18 20:07:53 +00:00
for _ , entity in ipairs ( entities ) do
-- Get to the root entity, as we are exporting entire entity trees.
2022-07-26 22:06:09 +00:00
local rootEntity = entity : GetRootEntity ( ) or entity
2022-07-28 09:57:47 +00:00
2022-07-18 20:07:53 +00:00
-- Make sure to only export entities when they are encountered the first time.
2022-07-26 22:06:09 +00:00
if file and not rootEntity : HasTag ( " MapCaptured " ) then
2022-07-19 16:27:31 +00:00
--print(rootEntity:GetFilename(), "got captured!")
2022-07-18 20:07:53 +00:00
-- 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.
2022-07-26 22:06:09 +00:00
if file : seek ( " end " ) == 0 then
2022-07-18 20:07:53 +00:00
-- First line.
2022-07-26 22:06:09 +00:00
file : write ( " [ \n \t " , JSON.Marshal ( rootEntity ) , " \n " , " ] " )
2022-07-18 20:07:53 +00:00
else
-- Following lines.
2022-07-26 22:06:09 +00:00
file : seek ( " end " , - 2 ) -- Seek a few bytes back, so we can overwrite some stuff.
file : write ( " , \n \t " , JSON.Marshal ( rootEntity ) , " \n " , " ] " )
2022-07-18 20:07:53 +00:00
end
2022-08-27 12:32:01 +00:00
-- Disabling this component will prevent entities from being killed/reset when they go offscreen.
2022-07-28 15:00:24 +00:00
-- If they are reset, all tags will be reset and we may capture these entities multiple times.
-- This has some side effects, like longleg.xml and zombie_weak.xml will respawn every revisit, as their spawner doesn't get deleted. (Or something similar to this)
local components = rootEntity : GetComponents ( " CameraBoundComponent " )
for _ , component in ipairs ( components ) do
rootEntity : SetComponentsEnabled ( component , false )
end
2022-07-18 20:07:53 +00:00
-- Prevent recapturing.
rootEntity : AddTag ( " MapCaptured " )
2022-07-26 22:06:09 +00:00
end
2022-07-18 20:07:53 +00:00
2022-07-26 22:06:09 +00:00
-- Make sure to only modify entities when they are encountered the first time.
2022-07-28 10:38:26 +00:00
-- Also, don't modify the player.
if modify and not rootEntity : IsPlayer ( ) and not rootEntity : HasTag ( " MapModified " ) then
2022-07-18 20:07:53 +00:00
-- Disable some components.
2022-07-26 22:06:09 +00:00
for _ , componentTypeName in ipairs ( Config.ComponentsToDisable ) do
2022-07-18 20:07:53 +00:00
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 )
2022-07-19 11:50:30 +00:00
component : SetValue ( " mVelocity " , 0 , 0 )
2022-07-18 20:07:53 +00:00
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.
2022-07-19 11:50:30 +00:00
--[[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 ] ]
-- Try to prevent some stuff from exploding.
local component = rootEntity : GetFirstComponent ( " PhysicsBody2Component " )
if component then
component : SetValue ( " kill_entity_if_body_destroyed " , false )
component : SetValue ( " destroy_body_if_entity_destroyed " , false )
component : SetValue ( " auto_clean " , false )
end
-- Try to prevent some stuff from exploding.
local component = rootEntity : GetFirstComponent ( " DamageModelComponent " )
if component then
component : SetValue ( " falling_damages " , false )
end
-- Try to prevent some stuff from exploding.
local component = rootEntity : GetFirstComponent ( " ExplodeOnDamageComponent " )
if component then
component : SetValue ( " explode_on_death_percent " , 0 )
end
-- Try to prevent some stuff from exploding.
local component = rootEntity : GetFirstComponent ( " MaterialInventoryComponent " )
if component then
component : SetValue ( " on_death_spill " , false )
component : SetValue ( " kill_when_empty " , false )
end
2022-07-18 20:07:53 +00:00
2022-07-26 22:06:09 +00:00
-- Prevent it from being modified again.
rootEntity : AddTag ( " MapModified " )
2022-07-18 20:07:53 +00:00
end
end
-- Ensure everything is written to disk before noita decides to crash.
2022-07-26 22:06:09 +00:00
if file then
file : flush ( )
end
2022-07-19 11:50:30 +00:00
end
2022-07-26 22:06:09 +00:00
---
---@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
2022-07-24 20:05:34 +00:00
2022-08-10 18:41:57 +00:00
-- Create or reopen entities JSON file.
2022-07-26 22:06:09 +00:00
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
2022-07-24 20:05:34 +00:00
2022-07-26 22:06:09 +00:00
return file
2022-07-24 20:05:34 +00:00
end
2022-07-26 22:06:09 +00:00
---Starts entity capturing and modification.
---Use `Capture.EntityCapturingCtx` to stop, control or view the progress.
---@param store boolean -- Will create a file and write all encountered entities into it.
---@param modify boolean -- Will modify all encountered entities.
function Capture : StartCapturingEntities ( store , modify )
-- There is nothing to capture, don't start anything.
if not store and not modify then return end
local file
---Process initialization callback.
---@param ctx ProcessRunnerCtx
local function handleInit ( ctx )
-- Create output file if requested.
file = store and createOrOpenEntityCaptureFile ( ) or nil
end
2020-06-01 20:39:00 +00:00
2022-07-26 22:06:09 +00:00
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo ( ctx )
2022-07-24 20:05:34 +00:00
repeat
2022-07-26 22:06:09 +00:00
local pos , radius = CameraAPI : GetPos ( ) , 5000 -- Returns the virtual coordinates of the screen center.
captureModifyEntities ( file , modify , pos.x , pos.y , radius )
2022-07-24 20:05:34 +00:00
2020-06-01 20:39:00 +00:00
wait ( 0 )
2022-07-26 22:06:09 +00:00
until ctx : IsStopping ( )
2022-07-24 20:05:34 +00:00
end
2020-06-01 20:39:00 +00:00
2022-07-26 22:06:09 +00:00
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd ( ctx )
if file then file : close ( ) end
2019-11-02 23:58:03 +00:00
end
2019-11-03 22:13:55 +00:00
2022-07-26 22:06:09 +00:00
---Error handler callback.
---@param err string
---@param scope "init"|"do"|"end"
local function handleErr ( err , scope )
print ( string.format ( " Failed to capture entities: %s " , err ) )
2022-07-27 23:48:49 +00:00
Message : ShowRuntimeError ( " EntitiesCaptureError " , " Failed to capture entities: " , tostring ( err ) )
2020-10-20 13:29:28 +00:00
end
2019-11-02 20:37:10 +00:00
2022-07-26 22:06:09 +00:00
-- Run process, if there is no other running right now.
self.EntityCapturingCtx : Run ( handleInit , handleDo , handleEnd , handleErr )
2022-07-24 20:05:34 +00:00
end
2022-07-27 23:48:49 +00:00
2022-08-10 18:41:57 +00:00
---Writes the current player position and other stats onto disk.
---@param file file*|nil
---@param pos Vec2
---@param oldPos Vec2
---@param hp number
---@param maxHP number
---@param polymorphed boolean
local function writePlayerPathEntry ( file , pos , oldPos , hp , maxHP , polymorphed )
if not file then return end
local struct = {
from = oldPos ,
to = pos ,
hp = hp ,
maxHP = maxHP ,
polymorphed = polymorphed ,
}
-- 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 file : seek ( " end " ) == 0 then
-- First line.
file : write ( " [ \n \t " , JSON.Marshal ( struct ) , " \n " , " ] " )
else
-- Following lines.
file : seek ( " end " , - 2 ) -- Seek a few bytes back, so we can overwrite some stuff.
file : write ( " , \n \t " , JSON.Marshal ( struct ) , " \n " , " ] " )
end
-- Ensure everything is written to disk before noita decides to crash.
file : flush ( )
end
---
---@return file*|nil
local function createOrOpenPlayerPathCaptureFile ( )
-- Make sure the file exists.
local file = io.open ( " mods/noita-mapcap/output/player-path.json " , " a " )
if file ~= nil then file : close ( ) end
-- Create or reopen JSON file.
file = io.open ( " mods/noita-mapcap/output/player-path.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
---Starts capturing the player path.
---Use `Capture.PlayerPathCapturingCtx` to stop, control or view the progress.
---@param interval integer|nil -- Wait time between captures in frames.
2022-08-11 08:56:24 +00:00
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture : StartCapturingPlayerPath ( interval , outputPixelScale )
2022-08-10 18:41:57 +00:00
interval = interval or 20
2022-08-11 08:56:24 +00:00
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords : PixelScale ( )
end
2022-08-10 18:41:57 +00:00
local file
local oldPos
---Process initialization callback.
---@param ctx ProcessRunnerCtx
local function handleInit ( ctx )
-- Create output file if requested.
file = createOrOpenPlayerPathCaptureFile ( )
end
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo ( ctx )
repeat
-- Get player entity, even if it is polymorphed.
-- For some reason Noita crashes when querying the "is_player" GameStatsComponent value on a freshly polymorphed entity found by its "player_unit" tag.
-- It seems that the entity can still be found by the tag, but its components/values can't be accessed anymore.
-- Solution: Don't do that.
---@type NoitaEntity|nil
local playerEntity
-- Try to find the regular player entity.
for _ , entity in ipairs ( EntityAPI.GetWithTag ( " player_unit " ) ) do
playerEntity = entity
break
end
-- If no player_unit entity was found, check if the player is any of the polymorphed entities.
if not playerEntity then
for _ , entity in ipairs ( EntityAPI.GetWithTag ( " polymorphed " ) ) do
local gameStatsComponent = entity : GetFirstComponent ( " GameStatsComponent " )
if gameStatsComponent and gameStatsComponent : GetValue ( " is_player " ) then
playerEntity = entity
break
end
end
end
-- Found some player entity.
if playerEntity then
-- Get position.
local x , y , rotation , scaleX , scaleY = playerEntity : GetTransform ( )
2022-08-11 08:56:24 +00:00
local pos = Vec2 ( x , y ) * outputPixelScale
2022-08-10 18:41:57 +00:00
-- Get some other stats from the player.
local damageModel = playerEntity : GetFirstComponent ( " DamageModelComponent " )
local hp , maxHP
if damageModel then
hp , maxHP = damageModel : GetValue ( " hp " ) , damageModel : GetValue ( " max_hp " )
end
local polymorphed = playerEntity : HasTag ( " polymorphed " )
if oldPos then writePlayerPathEntry ( file , pos , oldPos , hp , maxHP , polymorphed ) end
oldPos = pos
end
wait ( interval )
until ctx : IsStopping ( )
end
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd ( ctx )
if file then file : close ( ) end
end
---Error handler callback.
---@param err string
---@param scope "init"|"do"|"end"
local function handleErr ( err , scope )
print ( string.format ( " Failed to capture player path: %s " , err ) )
Message : ShowRuntimeError ( " PlayerPathCaptureError " , " Failed to capture player path: " , tostring ( err ) )
end
-- Run process, if there is no other running right now.
self.PlayerPathCapturingCtx : Run ( handleInit , handleDo , handleEnd , handleErr )
end
2022-07-27 23:48:49 +00:00
---Starts the capturing process based on user/mod settings.
function Capture : StartCapturing ( )
2022-07-28 09:57:47 +00:00
Message : CatchException ( " Capture:StartCapturing " , function ( )
2022-07-27 23:48:49 +00:00
2022-07-28 09:57:47 +00:00
local mode = ModSettingGet ( " noita-mapcap.capture-mode " )
local outputPixelScale = ModSettingGet ( " noita-mapcap.pixel-scale " )
local captureGridSize = tonumber ( ModSettingGet ( " noita-mapcap.grid-size " ) )
2022-07-27 23:48:49 +00:00
2022-07-28 09:57:47 +00:00
if mode == " live " then
2022-07-28 12:01:44 +00:00
self : StartCapturingLive ( outputPixelScale )
2022-08-11 08:56:24 +00:00
self : StartCapturingPlayerPath ( 5 , outputPixelScale ) -- Capture player path with an interval of 5 frames.
2022-07-28 09:57:47 +00:00
elseif mode == " area " then
local area = ModSettingGet ( " noita-mapcap.area " )
if area == " custom " then
local topLeft = Vec2 ( ModSettingGet ( " noita-mapcap.area-top-left " ) )
local bottomRight = Vec2 ( ModSettingGet ( " noita-mapcap.area-bottom-right " ) )
self : StartCapturingArea ( topLeft , bottomRight , captureGridSize , outputPixelScale )
else
local predefinedArea = Config.CaptureArea [ area ]
if predefinedArea then
self : StartCapturingArea ( predefinedArea.TopLeft , predefinedArea.BottomRight , captureGridSize , outputPixelScale )
else
Message : ShowRuntimeError ( " PredefinedArea " , string.format ( " Unknown predefined capturing area %q " , tostring ( area ) ) )
end
end
elseif mode == " spiral " then
local origin = ModSettingGet ( " noita-mapcap.capture-mode-spiral-origin " )
if origin == " custom " then
local originVec = Vec2 ( ModSettingGet ( " noita-mapcap.capture-mode-spiral-origin-vector " ) )
self : StartCapturingSpiral ( originVec , captureGridSize , outputPixelScale )
2022-07-28 10:06:47 +00:00
elseif origin == " 0 " then
local originVec = Vec2 ( 0 , 0 )
self : StartCapturingSpiral ( originVec , captureGridSize , outputPixelScale )
elseif origin == " current " then
local originVec = CameraAPI : GetPos ( )
self : StartCapturingSpiral ( originVec , captureGridSize , outputPixelScale )
2022-07-28 09:57:47 +00:00
else
Message : ShowRuntimeError ( " SpiralOrigin " , string.format ( " Unknown spiral origin %q " , tostring ( origin ) ) )
end
else
Message : ShowRuntimeError ( " StartCapturing " , string.format ( " Unknown capturing mode %q " , tostring ( mode ) ) )
end
2022-07-28 10:38:26 +00:00
-- Start entity capturing and modification, if wanted.
local captureEntities = ModSettingGet ( " noita-mapcap.capture-entities " )
local modifyEntities = ModSettingGet ( " noita-mapcap.modify-entities " )
self : StartCapturingEntities ( captureEntities , modifyEntities )
2022-07-28 09:57:47 +00:00
end )
2022-07-27 23:48:49 +00:00
end
---Stops all capturing processes.
function Capture : StopCapturing ( )
self.EntityCapturingCtx : Stop ( )
self.MapCapturingCtx : Stop ( )
2022-08-10 18:41:57 +00:00
self.PlayerPathCapturingCtx : Stop ( )
2022-07-27 23:48:49 +00:00
end