diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..44e1178 --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +Third-Party Assets +================== + +The following assets are licensed under the Apache License, Version 2.0: + +- src/views/SemanticWorkflow/Resources/loop.png +- src/views/SemanticWorkflow/Resources/loop_light.png + + Source: Google Material Symbols — "cycle" icon + URL: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:cycle + License: Apache-2.0 diff --git a/src/core/Locales.lua b/src/core/Locales.lua index 2f3f6be..353102a 100644 --- a/src/core/Locales.lua +++ b/src/core/Locales.lua @@ -98,6 +98,8 @@ Locales = {} ---@field public SEMANTIC_WORKFLOW_INPUTS_END_ACTION string ---@field public SEMANTIC_WORKFLOW_INPUTS_END_ACTION_TOOL_TIP string ---@field public SEMANTIC_WORKFLOW_INPUTS_END_ACTION_TYPE_TO_SEARCH_TOOL_TIP string +---@field public SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET string +---@field public SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET_TOOL_TIP string ---@field public SEMANTIC_WORKFLOW_PREFERENCES_EDIT_ENTIRE_STATE string ---@field public SEMANTIC_WORKFLOW_PREFERENCES_FAST_FORWARD string ---@field public SEMANTIC_WORKFLOW_PREFERENCES_DEFAULT_SECTION_TIMEOUT string diff --git a/src/res/lang/en_US.lua b/src/res/lang/en_US.lua index efd2d96..d99552d 100644 --- a/src/res/lang/en_US.lua +++ b/src/res/lang/en_US.lua @@ -116,6 +116,8 @@ This action cannot be undone. SEMANTIC_WORKFLOW_INPUTS_END_ACTION = 'End action:', SEMANTIC_WORKFLOW_INPUTS_END_ACTION_TOOL_TIP = 'End section when Mario enters this action', SEMANTIC_WORKFLOW_INPUTS_END_ACTION_TYPE_TO_SEARCH_TOOL_TIP = 'Type to filter actions', + SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET = 'Target', + SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET_TOOL_TIP = 'Click an input to set it as the loop jump target', SEMANTIC_WORKFLOW_PREFERENCES_TAB_NAME = 'Preferences', SEMANTIC_WORKFLOW_PREFERENCES_EDIT_ENTIRE_STATE = 'Edit entire state', SEMANTIC_WORKFLOW_PREFERENCES_FAST_FORWARD = 'Fast Forward', diff --git a/src/res/lang/fr_FR.lua b/src/res/lang/fr_FR.lua index dcb33f6..7d9f61e 100644 --- a/src/res/lang/fr_FR.lua +++ b/src/res/lang/fr_FR.lua @@ -118,6 +118,8 @@ Cette action est irréversible. SEMANTIC_WORKFLOW_INPUTS_END_ACTION = 'Action de fin :', SEMANTIC_WORKFLOW_INPUTS_END_ACTION_TOOL_TIP = 'Terminer la section quand Mario entre dans cette action', SEMANTIC_WORKFLOW_INPUTS_END_ACTION_TYPE_TO_SEARCH_TOOL_TIP = 'Taper pour filtrer les actions', + SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET = 'Cible', + SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET_TOOL_TIP = 'Cliquer un input pour le définir comme cible du saut de loop', SEMANTIC_WORKFLOW_PREFERENCES_EDIT_ENTIRE_STATE = 'Modifier l\'état entier', SEMANTIC_WORKFLOW_PREFERENCES_FAST_FORWARD = 'Avance rapide', SEMANTIC_WORKFLOW_PREFERENCES_DEFAULT_SECTION_TIMEOUT = 'Délai de section par défaut :', diff --git a/src/views/SemanticWorkflow/Definitions/SectionInputs.lua b/src/views/SemanticWorkflow/Definitions/SectionInputs.lua index 0bfdc5c..1ca6a18 100644 --- a/src/views/SemanticWorkflow/Definitions/SectionInputs.lua +++ b/src/views/SemanticWorkflow/Definitions/SectionInputs.lua @@ -4,10 +4,25 @@ -- SPDX-License-Identifier: GPL-2.0-or-later -- +---@class Loop Runtime information for a section loop. +---@field jump_target integer The 1-based index of the input to jump to within the same section. Can be the current input's index (self-loop), but must not be larger than the looping input's index. +---@field count integer The number of repetitions. May be zero. +---@field runtime_counter integer The current repetition during a sheet's run. +local cls_loop = {} + ---@class SectionInputs Describes the inputs to be made for one or more frames semantically. ---@field end_action integer|nil The 32-bit representation of Mario's action that, when reached in playback, terminates this input. ---@field timeout integer The maximum number of frames this input is held for. end_action may cause an earlier termination. ---@field tas_state table The TAS state to derive the control stick inputs from, which behaves mostly like the controls in the "TAS" view. ---@field joy table The joypad data, that is, pressed buttons and joystick values (joystick values only apply when the tas_state's movement_mode is set to "manual"). ---@field editing boolean Whether the input is selected for editing. +---@field loop Loop|nil The loop info to apply after this input, if applicable. local cls_section_inputs = {} + +function cls_section_inputs.new() end + +__impl = cls_section_inputs +dofile(views_path .. 'SemanticWorkflow/Implementations/SectionInputs.lua') +__impl = nil + +return cls_section_inputs diff --git a/src/views/SemanticWorkflow/Implementations/InputListGui.lua b/src/views/SemanticWorkflow/Implementations/InputListGui.lua index 79f5db8..cf3093e 100644 --- a/src/views/SemanticWorkflow/Implementations/InputListGui.lua +++ b/src/views/SemanticWorkflow/Implementations/InputListGui.lua @@ -262,16 +262,42 @@ local function draw_sections_gui(sheet, draw, section_rect, button_draw_data) end local deferred_calls = { } - local function queue_table_insert(target, reference_item, new_item, offset) + local function adjust_loop_targets_on_insert(section, insert_index) + for _, inp in ipairs(section.inputs) do + if inp.loop and inp.loop.jump_target >= insert_index then + inp.loop.jump_target = inp.loop.jump_target + 1 + end + end + end + + local function adjust_loop_targets_on_delete(section, delete_index) + for _, inp in ipairs(section.inputs) do + if inp.loop then + if inp.loop.jump_target > delete_index then + inp.loop.jump_target = inp.loop.jump_target - 1 + elseif inp.loop.jump_target == delete_index then + inp.loop = nil + end + end + end + end + + local function queue_table_insert(target, reference_item, new_item, offset, owning_section) deferred_calls[#deferred_calls+1] = function() - table.insert(target, IndexOf(target, reference_item) + offset, new_item) - -- any_changes = true -- TODO: is this even worth it? + local insert_index = IndexOf(target, reference_item) + offset + table.insert(target, insert_index, new_item) + if owning_section then + adjust_loop_targets_on_insert(owning_section, insert_index) + end end end - local function queue_table_remove(target, item) + local function queue_table_remove(target, item, owning_section) deferred_calls[#deferred_calls+1] = function() - table.remove(target, IndexOf(target, item)) - -- any_changes = true -- TODO: is this even worth it? + local delete_index = IndexOf(target, item) + table.remove(target, delete_index) + if owning_section then + adjust_loop_targets_on_delete(owning_section, delete_index) + end end end @@ -388,7 +414,7 @@ local function draw_sections_gui(sheet, draw, section_rect, button_draw_data) text = '[icon:clone_up]', tooltip = Locales.str("SEMANTIC_WORKFLOW_INPUTS_PREPEND_INPUT_TOOL_TIP") }) then - queue_table_insert(section.inputs, input, ugui.internal.deep_clone(input), 0) + queue_table_insert(section.inputs, input, ugui.internal.deep_clone(input), 0, section) end if ugui.button({ @@ -397,7 +423,7 @@ local function draw_sections_gui(sheet, draw, section_rect, button_draw_data) text = '[icon:clone_down]', tooltip = Locales.str("SEMANTIC_WORKFLOW_INPUTS_APPEND_INPUT_TOOL_TIP") }) then - queue_table_insert(section.inputs, input, ugui.internal.deep_clone(input), 1) + queue_table_insert(section.inputs, input, ugui.internal.deep_clone(input), 1, section) end if ugui.button({ @@ -407,7 +433,7 @@ local function draw_sections_gui(sheet, draw, section_rect, button_draw_data) tooltip = Locales.str("SEMANTIC_WORKFLOW_INPUTS_DELETE_INPUT_TOOL_TIP"), is_enabled = #section.inputs > 1 }) then - queue_table_remove(section.inputs, input) + queue_table_remove(section.inputs, input, section) end local termination_tool_tip = @@ -490,6 +516,18 @@ local function draw_sections_gui(sheet, draw, section_rect, button_draw_data) BreitbandGraphics.draw_ellipse(rect, input.joy[v.input] and '#000000FF' or '#00000050', 1) end + local active = sheet.active_input + if active and active.section_index == section_index + and section.inputs[active.input_index] + and section.inputs[active.input_index].loop + and section.inputs[active.input_index].loop.jump_target == input_index then + BreitbandGraphics.draw_rectangle(section_rect, '#FF8000FF', 2) + end + + if input.loop then + BreitbandGraphics.draw_rectangle(section_rect, '#0064FFFF', 2) + end + if section_index == sheet.preview_input.section_index and sheet.preview_input.input_index == input_index then BreitbandGraphics.draw_rectangle(section_rect, '#FF0000FF', 1) end diff --git a/src/views/SemanticWorkflow/Implementations/InputsTab.lua b/src/views/SemanticWorkflow/Implementations/InputsTab.lua index 5e2bbe9..90aef0e 100644 --- a/src/views/SemanticWorkflow/Implementations/InputsTab.lua +++ b/src/views/SemanticWorkflow/Implementations/InputsTab.lua @@ -1,3 +1,4 @@ + -- -- Copyright (c) 2025, Mupen64 maintainers. -- @@ -65,6 +66,9 @@ local UID = UIDProvider.allocate_once('InputsTab', function(enum_next) EndAction = enum_next(), EndActionTextbox = enum_next(), AvailableActions = enum_next(MAX_ACTION_GUESSES), + LoopToggle = enum_next(), + LoopSelectTarget = enum_next(), + LoopCount = enum_next(), } end) @@ -120,6 +124,97 @@ local function controls_for_end_action(input, draw, column, top) end end +---@param section Section +---@param own_index integer +---@param new_target integer +---@return boolean +local function is_loop_target_valid(section, own_index, new_target) + for other_index, other_input in ipairs(section.inputs) do + if other_index ~= own_index and other_input.loop then + local other_target = other_input.loop.jump_target + if other_target then + -- Overlap check covers nested, interlaced, and more edge cases + local overlaps = (new_target <= other_index) and (other_target <= own_index) + if overlaps then + return false + end + end + end + end + return true +end + +---@param input SectionInputs +---@return boolean any_changes +local function controls_for_loop(input, draw, column, top) + local any_changes = false + local had_loop = input.loop ~= nil + local has_loop = ugui.toggle_button({ + uid = UID.LoopToggle, + rectangle = grid_rect(column, top, Gui.MEDIUM_CONTROL_HEIGHT, Gui.MEDIUM_CONTROL_HEIGHT), + text = "[icon:loop]", + is_checked = had_loop, + styler_mixin = { icon_size = 14 }, + }) + if not has_loop then + input.loop = nil + elseif input.loop == nil then + local own_index = nil + for _, section in ipairs(SemanticWorkflowProject:asserted_current().sections) do + own_index = IndexOf(section.inputs, input) + if own_index then break end + end + input.loop = { + count = 1, + jump_target = own_index or 1, + runtime_counter = 0, + } + end + any_changes = any_changes or (had_loop ~= (input.loop ~= nil)) + + if input.loop then + local old_count = input.loop.count + input.loop.count = ugui.numberbox({ + uid = UID.LoopCount, + rectangle = grid_rect(column + 1, top, 2, Gui.MEDIUM_CONTROL_HEIGHT), + places = 2, + value = input.loop.count, + }) + any_changes = any_changes or old_count ~= input.loop.count + + if ugui.button({ + uid = UID.LoopSelectTarget, + rectangle = grid_rect(column + 3, top, 3, Gui.MEDIUM_CONTROL_HEIGHT), + text = Locales.str("SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET"), + tooltip = Locales.str("SEMANTIC_WORKFLOW_INPUTS_LOOP_TARGET_TOOL_TIP"), + }) then + InputListGui.special_select_handler = function(selection) + local sheet = SemanticWorkflowProject:asserted_current() + local current_section_index = nil + local current_section = nil + local own_index = nil + for s_idx, section in ipairs(sheet.sections) do + own_index = IndexOf(section.inputs, input) + if own_index then + current_section_index = s_idx + current_section = section + break + end + end + if current_section_index ~= selection.section_index then return end + if own_index >= selection.input_index then + if not is_loop_target_valid(current_section, own_index, selection.input_index) then return end + input.loop.jump_target = selection.input_index + InputListGui.special_select_handler = nil + sheet:run_to_preview() + end + end + end + end + + return any_changes +end + local function section_controls_for_selected(draw, edited_input) local sheet = SemanticWorkflowProject:asserted_current() @@ -145,6 +240,10 @@ local function section_controls_for_selected(draw, edited_input) controls_for_end_action(edited_input, draw, 0, top) + if end_action_search_text == nil then + any_changes = any_changes or controls_for_loop(edited_input, draw, 0, top + 1) + end + if any_changes then sheet:run_to_preview() end @@ -244,7 +343,6 @@ local function select_atan_end(selection_input) end local function select_atan_start(selection_input) - print(selection_input) local sheet = SemanticWorkflowProject:asserted_current() previous_preview_input = sheet.preview_input sheet.preview_input = selection_input @@ -475,3 +573,4 @@ function __impl.render(draw) draw_funcs[InputListGui.view_index](draw, edited_input) end end + diff --git a/src/views/SemanticWorkflow/Implementations/Section.lua b/src/views/SemanticWorkflow/Implementations/Section.lua index fd8f657..d203650 100644 --- a/src/views/SemanticWorkflow/Implementations/Section.lua +++ b/src/views/SemanticWorkflow/Implementations/Section.lua @@ -8,11 +8,14 @@ ---@diagnostic disable-next-line:assign-type-mismatch local __impl = __impl +---@type SectionInputs +local SectionInputs = dofile(views_path .. 'SemanticWorkflow/Definitions/SectionInputs.lua') + function __impl.new(name) local tmp = {} CloneInto(tmp, Joypad.input) return { - inputs = { { tas_state = NewTASState(), joy = tmp, timeout = 1, end_action = 0 } }, + inputs = { SectionInputs.new() }, collapsed = false, name = name } diff --git a/src/views/SemanticWorkflow/Implementations/SectionInputs.lua b/src/views/SemanticWorkflow/Implementations/SectionInputs.lua new file mode 100644 index 0000000..5b4640e --- /dev/null +++ b/src/views/SemanticWorkflow/Implementations/SectionInputs.lua @@ -0,0 +1,22 @@ +-- +-- Copyright (c) 2025, Mupen64 maintainers. +-- +-- SPDX-License-Identifier: GPL-2.0-or-later +-- + +---@type SectionInputs +---@diagnostic disable-next-line:assign-type-mismatch +local __impl = __impl + +function __impl.new() + local tmp = {} + CloneInto(tmp, Joypad.input) + ---@type SectionInputs + return { + tas_state = NewTASState(), + joy = tmp, + timeout = 1, + end_action = 0, + editing = false, + } +end diff --git a/src/views/SemanticWorkflow/Implementations/Sheet.lua b/src/views/SemanticWorkflow/Implementations/Sheet.lua index 30bcfae..8217f7c 100644 --- a/src/views/SemanticWorkflow/Implementations/Sheet.lua +++ b/src/views/SemanticWorkflow/Implementations/Sheet.lua @@ -1,3 +1,4 @@ + -- -- Copyright (c) 2025, Mupen64 maintainers. -- @@ -65,13 +66,34 @@ function __impl:evaluate_frame() if (input.timeout and self._frame_counter >= input.timeout) or current_action == input.end_action then - self._input_index = self._input_index + 1 self._frame_counter = 0 - if #section.inputs < self._input_index then - self.measured_section_lengths[section] = self._section_frame_counter - self._section_frame_counter = 0 - self._section_index = self._section_index + 1 - self._input_index = 1 + local loop = input.loop + if loop == nil then + self._input_index = self._input_index + 1 + if #section.inputs < self._input_index then + self.measured_section_lengths[section] = self._section_frame_counter + self._section_frame_counter = 0 + self._section_index = self._section_index + 1 + self._input_index = 1 + end + else + local target_index = loop.jump_target + local runtime_counter = loop.runtime_counter or 0 + if target_index == nil or target_index < 1 or target_index > #section.inputs + or target_index > self._input_index + or (loop.count ~= 0 and runtime_counter >= loop.count) + then + self._input_index = self._input_index + 1 + if #section.inputs < self._input_index then + self.measured_section_lengths[section] = self._section_frame_counter + self._section_frame_counter = 0 + self._section_index = self._section_index + 1 + self._input_index = 1 + end + else + loop.runtime_counter = runtime_counter + 1 + self._input_index = target_index + end end end if self._section_index > self.preview_input.section_index @@ -101,15 +123,28 @@ function __impl:evaluate_frame() end ---@param sheet Sheet ----@param from_base boolean | nil -local function run_to_preview_internal(sheet, from_base) - sheet.busy = true - +local function reset_counters(sheet) sheet._section_index = 1 sheet._input_index = 1 sheet._frame_counter = 0 sheet._section_frame_counter = 0 + -- reset loop counters + for _, section in pairs(sheet.sections) do + for _, input in pairs(section.inputs) do + if input.loop then + input.loop.runtime_counter = 0 + end + end + end +end + +---@param sheet Sheet +---@param from_base boolean | nil +local function run_to_preview_internal(sheet, from_base) + sheet.busy = true + reset_counters(sheet) + if from_base == nil or from_base then if sheet._base_sheet ~= nil then if sheet._savestate == nil or sheet._base_sheet:invalidated() then @@ -173,6 +208,15 @@ function __impl:load(file, load_state) end CloneInto(self, contents) + -- ensure loop runtime_counters are initialized after load + for _, section in pairs(self.sections) do + for _, input in pairs(section.inputs) do + if input.loop and input.loop.runtime_counter == nil then + input.loop.runtime_counter = 0 + end + end + end + -- convert sheets pre 2.0.0 if contents.version:match("^%s*[vV]?(%d+)") == '1' then self._frame_counter = self._frame_counter or 0 @@ -216,3 +260,4 @@ function __impl:set_base_sheet(sheet) self._base_sheet = sheet self._savestate = nil end + diff --git a/src/views/SemanticWorkflow/Main.lua b/src/views/SemanticWorkflow/Main.lua index e3ccc8c..5f8f689 100644 --- a/src/views/SemanticWorkflow/Main.lua +++ b/src/views/SemanticWorkflow/Main.lua @@ -90,7 +90,7 @@ SemanticWorkflowDialog = nil local ugui_icon_draw = ugui.standard_styler.draw_icon local custom_icons = { 'navigate_back', 'arrow_up', 'arrow_down', 'base_sheet', 'without_save', 'delete', - 'next_page', 'previous_page', 'duplicate', 'action', 'clone_up', 'clone_down', 'merge_up'} + 'next_page', 'previous_page', 'duplicate', 'action', 'clone_up', 'clone_down', 'merge_up', 'loop'} ugui.standard_styler.draw_icon = function(rectangle, color, visual_state, key) local postfix = Drawing.IsLightMode() and '' or '_light' diff --git a/src/views/SemanticWorkflow/Resources/loop.png b/src/views/SemanticWorkflow/Resources/loop.png new file mode 100644 index 0000000..78f3a3e Binary files /dev/null and b/src/views/SemanticWorkflow/Resources/loop.png differ diff --git a/src/views/SemanticWorkflow/Resources/loop_light.png b/src/views/SemanticWorkflow/Resources/loop_light.png new file mode 100644 index 0000000..20af693 Binary files /dev/null and b/src/views/SemanticWorkflow/Resources/loop_light.png differ