From 417d04648156ae9fcdeed167997bd7c4ccc60265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dexter=20Castor=20D=C3=B6pping?= Date: Wed, 18 Sep 2024 02:17:38 +0200 Subject: [PATCH] Add animations viewer --- .../animation_viewer/anim_parser.lua | 218 ++++++++++++++ .../animation_viewer/anim_viewer.lua | 275 ++++++++++++++++++ component-explorer/deps/nxml.lua | 8 +- component-explorer/main.lua | 11 + 4 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 component-explorer/animation_viewer/anim_parser.lua create mode 100644 component-explorer/animation_viewer/anim_viewer.lua diff --git a/component-explorer/animation_viewer/anim_parser.lua b/component-explorer/animation_viewer/anim_parser.lua new file mode 100644 index 0000000..5e62ee1 --- /dev/null +++ b/component-explorer/animation_viewer/anim_parser.lua @@ -0,0 +1,218 @@ +local xml_viewer = {} + +---@class Sprite +---@field filename string +---@field default_animation string +---@field rect_animations RectAnimation[] +---@field rect_animations_by_name {[string]: RectAnimation} + +---@class RectAnimation +---@field name string +---@field frame_count integer +---@field frame_width integer +---@field frame_height integer +---@field frames_per_row integer +---@field shrink_by_one_pixel boolean +---@field pos_x integer +---@field pos_y integer +---@field frame_wait number + +---@param xml any +---@return Sprite? +---@return string[] errors +function xml_viewer.parse_sprite(xml) + local filename = "" + local default_animation = "" + local rect_animations = {} + local rect_animations_by_name = {} + local valid = true + + local errors = {} + + if not xml.attr.filename then + errors[#errors+1] = "Sprite is missing 'filename' field" + valid = false + else + filename = xml.attr.filename + if not ModImageDoesExist(filename) then + errors[#errors+1] = "Image file '" .. filename .. "' is not found" + valid = false + end + end + + if xml.attr.default_animation then + default_animation = xml.attr.default_animation + end + + for rect_xml in xml:each_of("RectAnimation") do + local rect_anim, rect_errors = xml_viewer.parse_rect_animation(rect_xml) + for _, rect_error in ipairs(rect_errors) do + errors[#errors+1] = rect_error + end + + if rect_anim then + rect_animations[#rect_animations+1] = rect_anim + if rect_animations_by_name[rect_anim.name] then + errors[#errors+1] = "Multiple RectAnimation with name: " .. rect_anim.name + end + rect_animations_by_name[rect_anim.name] = rect_anim + end + end + + local has_animations = false + for _, _ in pairs(rect_animations_by_name) do + has_animations = true + break + end + + if not has_animations then + errors[#errors+1] = "Sprite has no animations" + valid = false + elseif not rect_animations_by_name[default_animation] then + errors[#errors+1] = "Sprite has no default animation: " .. default_animation + end + + if not valid then + return nil, errors + end + + ---@type Sprite + local sprite = { + filename = filename, + default_animation = default_animation, + rect_animations = rect_animations, + rect_animations_by_name = rect_animations_by_name, + } + + return sprite, errors +end + +local function read_int(xml, attr, errors) + local value = tonumber(xml.attr[attr]) or 0 + local as_int = math.floor(value) + if value ~= as_int then + errors[#errors+1] = "Expected '" .. attr .. "' to be an integer but got a decimal number" + end + return as_int +end + +local function read_bool(xml, attr, errors) + local value = xml.attr[attr] + if value ~= "0" and value ~= "1" then + errors[#errors+1] = "Expected '" .. attr .. "' to be 0 or 1" + end + return value ~= "0" +end + +---@param xml any +---@return RectAnimation? +---@return string[] errors +function xml_viewer.parse_rect_animation(xml) + local name = "unknown" + local frame_count = 0 + local frame_width = 0 + local frame_height = 0 + local frames_per_row = 9999 + local shrink_by_one_pixel = false + local pos_x = 0 + local pos_y = 0 + local frame_wait = 0 + + local valid = true + local errors = {} + + if not xml.attr.name then + errors[#errors+1] = "RectAnimation is missing 'name' field" + else + name = xml.attr.name + end + + if not xml.attr.frame_count then + errors[#errors+1] = "RectAnimation is missing 'frame_count' field" + valid = false + else + frame_count = read_int(xml, "frame_count", errors) + if frame_count <= 0 then + errors[#errors+1] = "Bad 'frame_count' value" + valid = false + end + end + + if not xml.attr.frame_width then + errors[#errors+1] = "RectAnimation is missing 'frame_width' field" + valid = false + else + frame_width = read_int(xml, "frame_width", errors) + if frame_width < 0 then + errors[#errors] = "Invalid 'frame_width' value: " .. xml.attr.frame_width + valid = false + end + end + + if not xml.attr.frame_height then + errors[#errors+1] = "RectAnimation is missing 'frame_height' field" + valid = false + else + frame_height = read_int(xml, "frame_height", errors) + if frame_height < 0 then + errors[#errors] = "Invalid 'frame_height' value: " .. xml.attr.frame_height + valid = false + end + end + + if xml.attr.frames_per_row then + frames_per_row = read_int(xml, "frames_per_row", errors) + end + + if xml.attr.shrink_by_one_pixel then + shrink_by_one_pixel = read_bool(xml, "shrink_by_one_pixel", errors) + end + + if xml.attr.pos_x then + pos_x = read_int(xml, "pos_x", errors) + if pos_x < 0 then + errors[#errors] = "Invalid 'pos_x' value: " .. xml.attr.pos_x + valid = false + end + end + + if xml.attr.pos_y then + pos_y = read_int(xml, "pos_y", errors) + if pos_y < 0 then + errors[#errors] = "Invalid 'pos_y' value: " .. xml.attr.pos_y + valid = false + end + end + + if not xml.attr.frame_wait then + errors[#errors+1] = "RectAnimation is missing 'frame_wait' field" + valid = false + else + frame_wait = tonumber(xml.attr.frame_wait) or 0 + if frame_wait <= 0 then + errors[#errors+1] = "Invalid 'frame_wait' value: " .. xml.attr.frame_wait + valid = false + end + end + + if not valid then + return nil, errors + end + + ---@type RectAnimation + local rect_animation = { + name = name, + frame_count = frame_count, + frame_width = frame_width, + frame_height = frame_height, + frames_per_row = frames_per_row, + shrink_by_one_pixel = shrink_by_one_pixel, + pos_x = pos_x, + pos_y = pos_y, + frame_wait = frame_wait, + } + + return rect_animation, errors +end + +return xml_viewer diff --git a/component-explorer/animation_viewer/anim_viewer.lua b/component-explorer/animation_viewer/anim_viewer.lua new file mode 100644 index 0000000..3979b1c --- /dev/null +++ b/component-explorer/animation_viewer/anim_viewer.lua @@ -0,0 +1,275 @@ +---@module 'component-explorer.animation_viewer.anim_parser' +local anim_parser = dofile_once("mods/component-explorer/animation_viewer/anim_parser.lua") + +---@module 'component-explorer.deps.nxml' +local nxml = dofile_once("mods/component-explorer/deps/nxml.lua") + +local ModTextFileGetContent = ModTextFileGetContent + +local anim_viewer = {} +anim_viewer.open = false + +local file_path = "" +local path_content = "" + +local direct_input_text = "" + +---@class AnimState +---@field frame integer +---@field current_animation string +---@field time number +---@field sprite Sprite? +---@field play boolean +---@field xml_error string? +---@field sprite_errors string[] + +---@return AnimState +local function make_state() + ---@type AnimState + local new_state = { + frame = 0, + current_animation = "unknown", + time = 0, + sprite = nil, + play = false, + xml_error = nil, + sprite_errors = {}, + } + return new_state +end + +---@param state AnimState +local function state_reset(state) + state.xml_error = nil + state.sprite = nil + state.sprite_errors = {} +end + +local direct_input_state = make_state() +local path_state = make_state() + +---Shows image from xml file +---@param sprite Sprite +---@param animation string +---@param frame integer +local function show_sprite_frame(sprite, animation, frame) + local avail_w, avail_h = imgui.GetContentRegionAvail() + if avail_w <= 0 or avail_h <= 0 then + return + end + + local rect_anim = sprite.rect_animations_by_name[animation] + local img = imgui.LoadImage(sprite.filename) + if img then + local frame_column = frame % rect_anim.frames_per_row + local frame_row = math.floor(frame / rect_anim.frames_per_row) + + local uv0_x = (rect_anim.pos_x + frame_column * rect_anim.frame_width) / img.width + local uv0_y = (rect_anim.pos_y + frame_row * rect_anim.frame_height) / img.height + + local pixels_w = rect_anim.frame_width + local pixels_h = rect_anim.frame_height + if rect_anim.shrink_by_one_pixel then + pixels_w = pixels_w - 1 + pixels_h = pixels_h - 1 + end + local uv1_x = uv0_x + pixels_w / img.width + local uv1_y = uv0_y + pixels_h / img.height + + local display_ratio = pixels_w / pixels_h + + local avail_ratio = avail_w / avail_h + + local disp_w, disp_h + local x, y = imgui.GetCursorPos() + if display_ratio < avail_ratio then + disp_h = avail_h + disp_w = disp_h * display_ratio + x = x + (avail_w - disp_w) / 2 + else + disp_w = avail_w + disp_h = disp_w / display_ratio + y = y + (avail_h - disp_h) / 2 + end + imgui.SetCursorPos(x, y) -- Center + + imgui.Image(img, disp_w, disp_h, uv0_x, uv0_y, uv1_x, uv1_y) + end +end + +---@param state AnimState +---@param xml_source string +local function load_animation(state, xml_source) + state_reset(state) + + local parse_errors = {} + local error_reporter = function(type, msg) + parse_errors[#parse_errors+1] = "parser warning: [" .. type .. "] " .. msg + end + + local success, result = pcall(nxml.parse, xml_source, {error_reporter=error_reporter}) + if success then + state.sprite, state.sprite_errors = anim_parser.parse_sprite(result) + else + parse_errors[#parse_errors+1] = result + end + + if #parse_errors > 0 then + state.xml_error = table.concat(parse_errors, "\n") + else + state.xml_error = nil + end +end + + +function anim_viewer.show() + local should_show + should_show, anim_viewer.open = imgui.Begin("Animation", anim_viewer.open) + if not should_show then + return + end + + ---@type AnimState + local state + + if imgui.BeginTabBar("inputmethod") then + if imgui.BeginTabItem("File Path") then + state = path_state + + local changed + imgui.SetNextItemWidth(450) + changed, file_path = imgui.InputText("Path", file_path) + if changed then + state_reset(state) + path_content = "" + if file_path:sub(-4) == ".xml" then + path_content = ModTextFileGetContent(file_path) or "" + if path_content == "" then + state.xml_error = "File not found" + else + load_animation(state, path_content) + end + + end + end + + if path_content ~= "" then + imgui.SameLine() + if imgui.Button("Edit") then + switch_direct_input = true + direct_input_text = path_content + direct_input_state = path_state + + path_state = make_state() + file_path = "" + end + end + + imgui.EndTabItem() + end + + local di_tab_flags = 0 + if switch_direct_input then + switch_direct_input = false + di_tab_flags = bit.bor(di_tab_flags, imgui.TabItemFlags.SetSelected) + end + if imgui.BeginTabItem("Direct Input", nil, di_tab_flags) then + state = direct_input_state + + local changed + imgui.PushItemWidth(-1) + changed, direct_input_text = imgui.InputTextMultiline("##directinput", direct_input_text, 0, imgui.GetTextLineHeight() * 10) + imgui.PopItemWidth() + if changed then + load_animation(state, direct_input_text) + end + imgui.EndTabItem() + end + + imgui.EndTabBar() + end + + local error_count = #state.sprite_errors + if state.xml_error then + error_count = error_count + 1 + end + + if error_count > 0 and imgui.CollapsingHeader("Errors (" .. error_count .. ")###sprite_errors") then + if state.xml_error then + imgui.TextWrapped(state.xml_error) + end + + for _, sprite_error in ipairs(state.sprite_errors) do + imgui.BulletText(sprite_error) + end + end + + imgui.Separator() + + if state.sprite then + if not state.current_animation then state.current_animation = state.sprite.default_animation end + if not state.sprite.rect_animations_by_name[state.current_animation] then + for _, rect_anim in ipairs(state.sprite.rect_animations) do + state.current_animation = rect_anim.name + break + end + end + + imgui.SetNextItemWidth(190) + if imgui.BeginCombo("##anim", state.current_animation) then + for i, rect_anim in pairs(state.sprite.rect_animations) do + if imgui.Selectable(rect_anim.name .. "##" .. i, rect_anim.name == state.current_animation) then + state.current_animation = rect_anim.name + end + end + imgui.EndCombo() + end + + local rect_anim = state.sprite.rect_animations_by_name[state.current_animation] + + imgui.SameLine() + if imgui.Button(state.play and "Pause" or "Play") then + state.play = not state.play + end + + if state.play then + state.time = state.time + 1/60 + while state.time > rect_anim.frame_wait do + state.frame = (state.frame + 1) % rect_anim.frame_count + state.time = state.time - rect_anim.frame_wait + end + end + + imgui.PushButtonRepeat(true) + + imgui.SameLine() + if imgui.Button("<") then + state.frame = state.frame - 1 + end + + local _ + imgui.SameLine() + imgui.SetNextItemWidth(imgui.GetFontSize() * 2) + local frame_disp = state.frame + 1 + _, frame_disp = imgui.InputInt("###cf", frame_disp, 0) + state.frame = frame_disp-1 + + imgui.SameLine() + imgui.Text("/ " .. rect_anim.frame_count) + + imgui.SameLine() + if imgui.Button(">") then + state.frame = state.frame + 1 + end + + state.frame = state.frame % rect_anim.frame_count + + imgui.PopButtonRepeat() + + show_sprite_frame(state.sprite, state.current_animation, state.frame) + end + + imgui.End() +end + +return anim_viewer diff --git a/component-explorer/deps/nxml.lua b/component-explorer/deps/nxml.lua index eea4bb1..3fe027f 100644 --- a/component-explorer/deps/nxml.lua +++ b/component-explorer/deps/nxml.lua @@ -486,10 +486,11 @@ function XML_ELEMENT_FUNCS:each_child() end end -function nxml.parse(data) +function nxml.parse(data, options) + options = options or {} local data_len = #data local tok = new_tokenizer(str_normalize(data), data_len) - local parser = new_parser(tok) + local parser = new_parser(tok, options.error_reporter) local elem = parser:parse_element(false) @@ -501,9 +502,10 @@ function nxml.parse(data) end function nxml.parse_many(data) + options = options or {} local data_len = #data local tok = new_tokenizer(str_normalize(data), data_len) - local parser = new_parser(tok) + local parser = new_parser(tok, options.error_reporter) local elems = parser:parse_elements(false) diff --git a/component-explorer/main.lua b/component-explorer/main.lua index 8679e12..7c31d5f 100644 --- a/component-explorer/main.lua +++ b/component-explorer/main.lua @@ -57,6 +57,9 @@ local spawn_stuff = dofile_once("mods/component-explorer/spawn_stuff.lua") ---@module 'component-explorer.repeat_scripts' local repeat_scripts = dofile_once("mods/component-explorer/repeat_scripts.lua") +---@module 'component-explorer.animation_viewer.anim_viewer' +local anim_viewer = dofile_once("mods/component-explorer/animation_viewer/anim_viewer.lua") + -- Not used here right now, but depends on grabbing a function that's only -- supposed to be accessible during mod init. ---@module 'component-explorer.utils.file_util' @@ -147,6 +150,10 @@ function show_view_menu_items() _, file_viewer.open = imgui.MenuItem("File Viewer", sct("CTRL+SHIFT+F"), file_viewer.open) _, translations.open = imgui.MenuItem("Translations", "", translations.open) + if imgui.LoadImage then + _, anim_viewer.open = imgui.MenuItem("Animations", "", anim_viewer.open) + end + _, cursor.config_open = imgui.MenuItem("Cursor Config", sct("CTRL+SHIFT+C"), cursor.config_open) _, globals.open = imgui.MenuItem("Globals", "", globals.open) @@ -351,6 +358,10 @@ function update_ui(paused, current_frame_run) translations.show() end + if imgui.LoadImage and anim_viewer.open then + anim_viewer.show() + end + if cursor.config_open then cursor.config_show() end