From 1e5249d43630adadb1a22c22d9d4b2c63e8dd831 Mon Sep 17 00:00:00 2001 From: David Vogel Date: Wed, 10 Aug 2022 20:41:57 +0200 Subject: [PATCH] Add player path tracking and drawing in live mode --- .vscode/settings.json | 1 + bin/stitch/imagetile.go | 18 ++++- bin/stitch/imagetiles.go | 3 +- bin/stitch/player-path.go | 85 ++++++++++++++++++++++ bin/stitch/stitch.go | 27 ++++++- files/capture.lua | 144 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 bin/stitch/player-path.go diff --git a/.vscode/settings.json b/.vscode/settings.json index ee6784f..248b3dd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "mapcap", "nfnt", "noita", + "polymorphed", "prerender", "promptui", "rasterizer", diff --git a/bin/stitch/imagetile.go b/bin/stitch/imagetile.go index 080b135..7c239c2 100644 --- a/bin/stitch/imagetile.go +++ b/bin/stitch/imagetile.go @@ -31,7 +31,8 @@ type imageTile struct { pixelErrorSum uint64 // Sum of the difference between the (sub)pixels of all overlapping images. 0 Means that all overlapping images are identical. - entities []Entity // List of entities that may lie on or near this image tile. + entities []Entity // List of entities that may lie on or near this image tile. + playerPath *PlayerPath // Contains the player path. } func (it *imageTile) GetImage() (*image.RGBA, error) { @@ -101,6 +102,21 @@ func (it *imageTile) GetImage() (*image.RGBA, error) { r.Close() // This just transforms the image's luminance curve back from linear into non linear. } + // Draw player path. + if it.playerPath != nil { + c := canvas.New(float64(imgRGBA.Rect.Dx()), float64(imgRGBA.Rect.Dy())) + ctx := canvas.NewContext(c) + ctx.SetCoordSystem(canvas.CartesianIV) + ctx.SetCoordRect(canvas.Rect{X: -float64(oldRect.Min.X), Y: -float64(oldRect.Min.Y), W: float64(imgRGBA.Rect.Dx()), H: float64(imgRGBA.Rect.Dy())}, float64(imgRGBA.Rect.Dx()), float64(imgRGBA.Rect.Dy())) + + it.playerPath.Draw(ctx, scaledRect) + + // Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already. + r := rasterizer.FromImage(imgRGBA, canvas.DPMM(1.0), canvas.DefaultColorSpace) + c.Render(r) + r.Close() // This just transforms the image's luminance curve back from linear into non linear. + } + // Restore the position of the image rectangle. imgRGBA.Rect = scaledRect diff --git a/bin/stitch/imagetiles.go b/bin/stitch/imagetiles.go index 1fd3979..16bfbb8 100644 --- a/bin/stitch/imagetiles.go +++ b/bin/stitch/imagetiles.go @@ -22,7 +22,7 @@ import ( var regexFileParse = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`) -func loadImages(path string, entities []Entity, scaleDivider int) ([]imageTile, error) { +func loadImages(path string, entities []Entity, playerPath *PlayerPath, scaleDivider int) ([]imageTile, error) { var imageTiles []imageTile if scaleDivider < 1 { @@ -60,6 +60,7 @@ func loadImages(path string, entities []Entity, scaleDivider int) ([]imageTile, image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider), imageMutex: &sync.RWMutex{}, entities: entities, + playerPath: playerPath, }) } diff --git a/bin/stitch/player-path.go b/bin/stitch/player-path.go new file mode 100644 index 0000000..3054e8f --- /dev/null +++ b/bin/stitch/player-path.go @@ -0,0 +1,85 @@ +// Copyright (c) 2022 David Vogel +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package main + +import ( + "encoding/json" + "image" + "image/color" + "math" + "os" + + "github.com/tdewolff/canvas" +) + +var playerPathDisplayStyle = canvas.Style{ + FillColor: canvas.Transparent, + //StrokeColor: color.RGBA{0, 0, 0, 127}, + StrokeWidth: 3.0, + StrokeCapper: canvas.RoundCap, + StrokeJoiner: canvas.MiterJoin, + DashOffset: 0.0, + Dashes: []float64{}, + FillRule: canvas.NonZero, +} + +type PlayerPathElement struct { + From [2]float64 `json:"from"` + To [2]float64 `json:"to"` + HP float64 `json:"hp"` + MaxHP float64 `json:"maxHP"` + Polymorphed bool `json:"polymorphed"` +} + +type PlayerPath struct { + PathElements []PlayerPathElement +} + +func loadPlayerPath(path string) (*PlayerPath, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + + var result []PlayerPathElement + + jsonDec := json.NewDecoder(file) + if err := jsonDec.Decode(&result); err != nil { + return nil, err + } + + return &PlayerPath{PathElements: result}, nil +} + +func (p PlayerPath) Draw(c *canvas.Context, imgRect image.Rectangle) { + // Set drawing style. + c.Style = playerPathDisplayStyle + + for _, pathElement := range p.PathElements { + from, to := pathElement.From, pathElement.To + + // Only draw if the path may cross the image rectangle. + pathRect := image.Rectangle{image.Point{int(from[0]), int(from[1])}, image.Point{int(to[0]), int(to[1])}}.Canon().Inset(int(-playerPathDisplayStyle.StrokeWidth) - 1) + if pathRect.Overlaps(imgRect) { + path := &canvas.Path{} + path.MoveTo(from[0], from[1]) + path.LineTo(to[0], to[1]) + + if pathElement.Polymorphed { + // Set stroke color to typically polymorph color. + c.Style.StrokeColor = color.RGBA{127, 50, 83, 127} + } else { + // Set stroke color depending on HP level. + hpFactor := math.Max(math.Min(pathElement.HP/pathElement.MaxHP, 1), 0) + hpFactorInv := 1 - hpFactor + r, g, b, a := uint8((0*hpFactor+1*hpFactorInv)*127), uint8((1*hpFactor+0*hpFactorInv)*127), uint8(0), uint8(127) + c.Style.StrokeColor = color.RGBA{r, g, b, a} + } + + c.DrawPath(0, 0, path) + } + } +} diff --git a/bin/stitch/stitch.go b/bin/stitch/stitch.go index c8289c6..cf7d6e1 100644 --- a/bin/stitch/stitch.go +++ b/bin/stitch/stitch.go @@ -22,6 +22,7 @@ import ( var flagInputPath = flag.String("input", filepath.Join(".", "..", "..", "output"), "The source path of the image tiles to be stitched.") var flagEntitiesInputPath = flag.String("entities", filepath.Join(".", "..", "..", "output", "entities.json"), "The source path of the entities.json file.") +var flagPlayerPathInputPath = flag.String("player-path", filepath.Join(".", "..", "..", "output", "player-path.json"), "The source path of the player-path.json file.") var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image.") var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.") var flagXMin = flag.Int("xmin", 0, "Left bound of the output rectangle. This coordinate is included in the output.") @@ -102,8 +103,32 @@ func main() { log.Printf("Got %v entities.", len(entities)) } + // Query the user, if there were no cmd arguments given. + if flag.NFlag() == 0 { + prompt := promptui.Prompt{ + Label: "Enter \"player-path.json\" path:", + Default: *flagPlayerPathInputPath, + AllowEdit: true, + } + + result, err := prompt.Run() + if err != nil { + log.Panicf("Error while getting user input: %v", err) + } + *flagPlayerPathInputPath = result + } + + // Load player path if requested. + playerPath, err := loadPlayerPath(*flagPlayerPathInputPath) + if err != nil { + log.Printf("Failed to load player path: %v", err) + } + if playerPath != nil && len(playerPath.PathElements) > 0 { + log.Printf("Got %v player path entries.", len(playerPath.PathElements)) + } + log.Printf("Starting to read tile information at \"%v\"", *flagInputPath) - tiles, err := loadImages(*flagInputPath, entities, *flagScaleDivider) + tiles, err := loadImages(*flagInputPath, entities, playerPath, *flagScaleDivider) if err != nil { log.Panic(err) } diff --git a/files/capture.lua b/files/capture.lua index ffd6e44..e01c971 100644 --- a/files/capture.lua +++ b/files/capture.lua @@ -28,6 +28,7 @@ local Vec2 = require("noita-api.vec2") Capture.MapCapturingCtx = Capture.MapCapturingCtx or ProcessRunner.New() Capture.EntityCapturingCtx = Capture.EntityCapturingCtx or ProcessRunner.New() +Capture.PlayerPathCapturingCtx = Capture.PlayerPathCapturingCtx or ProcessRunner.New() ---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. @@ -158,7 +159,7 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale) ---The position in world coordinates. ---Centered to the grid. ---@type Vec2 - local pos = origin + Vec2(captureGridSize/2, captureGridSize/2) + local pos = origin + Vec2(captureGridSize / 2, captureGridSize / 2) ---Process main callback. ---@param ctx ProcessRunnerCtx @@ -238,7 +239,7 @@ function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outpu ---@param ctx ProcessRunnerCtx local function handleDo(ctx) Modification.SetCameraFree(true) - ctx.state = {Current = 0, Max = gridSize.x * gridSize.y} + ctx.state = { Current = 0, Max = gridSize.x * gridSize.y } while t < tLimit do -- Prematurely stop capturing if that is requested by the context. @@ -251,7 +252,7 @@ function Capture:StartCapturingArea(topLeft, bottomRight, captureGridSize, outpu ---Position in world coordinates. ---@type Vec2 local pos = (hilbertPos + gridTopLeft) * captureGridSize - pos:Add(Vec2(captureGridSize/2, captureGridSize/2)) -- Move to center of grid cell. + pos:Add(Vec2(captureGridSize / 2, captureGridSize / 2)) -- Move to center of grid cell. captureScreenshot(pos, true, true, ctx, outputPixelScale) ctx.state.Current = ctx.state.Current + 1 end @@ -450,7 +451,7 @@ local function createOrOpenEntityCaptureFile() local file = io.open("mods/noita-mapcap/output/entities.json", "a") if file ~= nil then file:close() end - -- Create or reopen entities CSV file. + -- Create or reopen entities JSON 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 @@ -503,6 +504,139 @@ function Capture:StartCapturingEntities(store, modify) self.EntityCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr) end +---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. +function Capture:StartCapturingPlayerPath(interval) + interval = interval or 20 + + 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() + local pos = Vec2(x, y) + + -- 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 + ---Starts the capturing process based on user/mod settings. function Capture:StartCapturing() Message:CatchException("Capture:StartCapturing", function() @@ -513,6 +647,7 @@ function Capture:StartCapturing() if mode == "live" then self:StartCapturingLive(outputPixelScale) + self:StartCapturingPlayerPath(5) -- Capture player path with an interval of 5 frames. elseif mode == "area" then local area = ModSettingGet("noita-mapcap.area") if area == "custom" then @@ -558,4 +693,5 @@ end function Capture:StopCapturing() self.EntityCapturingCtx:Stop() self.MapCapturingCtx:Stop() + self.PlayerPathCapturingCtx:Stop() end