Anim Editor: added NoUser property, added buttons to duplicate/delete particle and delete single commands

This commit is contained in:
Maruno17
2024-02-29 00:54:01 +00:00
parent 67acf46859
commit f0fae4b9ec
10 changed files with 229 additions and 62 deletions

View File

@@ -14,13 +14,14 @@ class UIControls::ControlsContainer
OFFSET_FROM_LABEL_X = 90
OFFSET_FROM_LABEL_Y = 0
def initialize(x, y, width, height)
def initialize(x, y, width, height, right_margin = 0)
@viewport = Viewport.new(x, y, width, height)
@viewport.z = 99999
@x = x
@y = y
@width = width
@height = height
@right_margin = right_margin
@label_offset_x = OFFSET_FROM_LABEL_X
@label_offset_y = OFFSET_FROM_LABEL_Y
@controls = []
@@ -193,7 +194,7 @@ class UIControls::ControlsContainer
def control_size(has_label = false)
if has_label
return @width - @label_offset_x, LINE_SPACING - @label_offset_y
return @width - @label_offset_x - @right_margin, LINE_SPACING - @label_offset_y
end
return @width, LINE_SPACING
end

View File

@@ -41,9 +41,14 @@ class UIControls::NumberTextBox < UIControls::TextBox
self.invalidate
end
# TODO: If current value is 0, replace it with ch instead of inserting ch?
def insert_char(ch)
self.value = @value.to_s.insert(@cursor_pos, ch).to_i
def insert_char(ch, index = -1)
old_val = @value
if @value == 0
@value = ch.to_i
else
self.value = @value.to_s.insert((index >= 0) ? index : @cursor_pos, ch).to_i
end
return if @value == old_val
@cursor_pos += 1
@cursor_pos = @cursor_pos.clamp(0, @value.to_s.length)
@cursor_timer = System.uptime
@@ -107,17 +112,20 @@ class UIControls::NumberTextBox < UIControls::TextBox
def update_text_entry
ret = false
Input.gets.each_char do |ch|
next if !["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-"].include?(ch)
if ch == "-"
next if @min_value >= 0 || @cursor_pos > 1 || (@cursor_pos > 0 && @value >= 0)
if @value < 0
case ch
when "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"
insert_char(ch)
ret = true
when "-", "+"
if @value > 0 && @min_value < 0 && ch == "-"
insert_char(ch, 0) # Add a negative sign at the start
ret = true
elsif @value < 0
delete_at(0) # Remove the negative sign
ret = true
next
end
next
end
insert_char(ch)
ret = true
end
return ret
end

View File

@@ -4,11 +4,12 @@
class UIControls::BitmapButton < UIControls::Button
BUTTON_PADDING = 4
def initialize(x, y, viewport, button_bitmap)
def initialize(x, y, viewport, button_bitmap, disabled_bitmap = nil)
super(button_bitmap.width + (BUTTON_PADDING * 2), button_bitmap.height + (BUTTON_PADDING * 2), viewport)
self.x = x
self.y = y
@button_bitmap = button_bitmap
@disabled_bitmap = disabled_bitmap
end
def set_interactive_rects
@@ -24,7 +25,12 @@ class UIControls::BitmapButton < UIControls::Button
def refresh
super
# Draw button bitmap
self.bitmap.blt(BUTTON_PADDING, BUTTON_PADDING, @button_bitmap,
Rect.new(0, 0, @button_bitmap.width, @button_bitmap.height))
if @disabled_bitmap && disabled?
self.bitmap.blt(BUTTON_PADDING, BUTTON_PADDING, @disabled_bitmap,
Rect.new(0, 0, @disabled_bitmap.width, @disabled_bitmap.height))
else
self.bitmap.blt(BUTTON_PADDING, BUTTON_PADDING, @button_bitmap,
Rect.new(0, 0, @button_bitmap.width, @button_bitmap.height))
end
end
end

View File

@@ -4,6 +4,7 @@ module GameData
attr_reader :move # Either the move's ID or the common animation's name (both are strings)
attr_reader :version # Hit number
attr_reader :name # Shown in the sublist; cosmetic only
attr_reader :no_user # Whether there is no "User" particle (false by default)
attr_reader :no_target # Whether there is no "Target" particle (false by default)
attr_reader :ignore # Whether the animation can't be played in battle
attr_reader :flags
@@ -14,7 +15,7 @@ module GameData
DATA_FILENAME = "animations.dat"
OPTIONAL = true
# TODO: All mentions of focus types can be found by searching for
# NOTE: All mentions of focus types can be found by searching for
# :user_and_target, plus there's :foreground in PARTICLE_DEFAULT_VALUES
# below.
# TODO: Add :user_ground, :target_ground?
@@ -30,6 +31,9 @@ module GameData
"TargetSide" => :target_side_foreground,
"TargetSideBackground" => :target_side_background,
}
FOCUS_TYPES_WITH_USER = [
:user, :user_and_target, :user_side_foreground, :user_side_background
]
FOCUS_TYPES_WITH_TARGET = [
:target, :user_and_target, :target_side_foreground, :target_side_background
]
@@ -48,6 +52,7 @@ module GameData
"SectionName" => [:id, "esU", {"Move" => :move, "OppMove" => :opp_move,
"Common" => :common, "OppCommon" => :opp_common}],
"Name" => [:name, "s"],
"NoUser" => [:no_user, "b"],
"NoTarget" => [:no_target, "b"],
"Ignore" => [:ignore, "b"],
# TODO: Boolean for whether the animation will be played if the target is
@@ -184,6 +189,7 @@ module GameData
ret[:move] = move if !move.nil?
ret[:version] = 0
ret[:name] = _INTL("New animation")
ret[:no_user] = false
ret[:no_target] = false
ret[:ignore] = false
ret[:particles] = [
@@ -202,6 +208,7 @@ module GameData
@move = hash[:move]
@version = hash[:version] || 0
@name = hash[:name]
@no_user = hash[:no_user] || false
@no_target = hash[:no_target] || false
@ignore = hash[:ignore] || false
@particles = hash[:particles] || []
@@ -217,6 +224,7 @@ module GameData
ret[:move] = @move
ret[:version] = @version
ret[:name] = @name
ret[:no_user] = @no_user
ret[:no_target] = @no_target
ret[:ignore] = @ignore
ret[:particles] = [] # Clone the @particles array, which is nested hashes and arrays

View File

@@ -94,12 +94,21 @@ module Compiler
hash[:type] = hash[:id][0]
hash[:move] = hash[:id][1]
hash[:version] = hash[:id][2] || 0
# Ensure there is at most one each of "User", "Target" and "SE" particles
["User", "Target", "SE"].each do |type|
next if hash[:particles].count { |particle| particle[:name] == type } <= 1
raise _INTL("Animation has more than 1 \"{1}\" particle, which isn't allowed.", type) + "\n" + FileLineData.linereport
end
# Ensure there is no "User" particle if "NoUser" is set
if hash[:particles].any? { |particle| particle[:name] == "User" } && hash[:no_user]
raise _INTL("Can't define a \"User\" particle and also set property \"NoUser\" to true.") + "\n" + FileLineData.linereport
end
# Ensure there is no "Target" particle if "NoTarget" is set
if hash[:particles].any? { |particle| particle[:name] == "Target" } && hash[:no_target]
raise _INTL("Can't define a \"Target\" particle and also set property \"NoTarget\" to true.") + "\n" + FileLineData.linereport
end
# Create "User", "SE" and "Target" particles if they don't exist but should
if hash[:particles].none? { |particle| particle[:name] == "User" }
# Create "User", "Target" and "SE" particles if they don't exist but should
if hash[:particles].none? { |particle| particle[:name] == "User" } && !hash[:no_user]
hash[:particles].push({:name => "User"})
end
if hash[:particles].none? { |particle| particle[:name] == "Target" } && !hash[:no_target]
@@ -145,8 +154,12 @@ module Compiler
when "Target" then particle[:graphic] = "TARGET"
end
end
# TODO: Ensure that particles don't have a focus involving a user if the
# animation itself doesn't involve a user.
# Ensure that particles don't have a focus involving a user if the
# animation itself doesn't involve a user
if hash[:no_user] && GameData::Animation::FOCUS_TYPES_WITH_USER.include?(particle[:focus])
raise _INTL("Particle \"{1}\" can't have a \"Focus\" that involves a user if property \"NoUser\" is set to true.",
particle[:name]) + "\n" + FileLineData.linereport
end
# Ensure that particles don't have a focus involving a target if the
# animation itself doesn't involve a target
if hash[:no_target] && GameData::Animation::FOCUS_TYPES_WITH_TARGET.include?(particle[:focus])

View File

@@ -2,10 +2,9 @@
#
#===============================================================================
class AnimationEditor
WINDOW_WIDTH = Settings::SCREEN_WIDTH + (32 * 10)
WINDOW_HEIGHT = Settings::SCREEN_HEIGHT + (32 * 10)
BORDER_THICKNESS = 4
WINDOW_WIDTH = Settings::SCREEN_WIDTH + 328 + (BORDER_THICKNESS * 4)
WINDOW_HEIGHT = Settings::SCREEN_HEIGHT + 320 + (BORDER_THICKNESS * 4)
# Components
MENU_BAR_WIDTH = WINDOW_WIDTH
@@ -25,6 +24,7 @@ class AnimationEditor
SIDE_PANE_Y = CANVAS_Y
SIDE_PANE_WIDTH = WINDOW_WIDTH - SIDE_PANE_X - BORDER_THICKNESS
SIDE_PANE_HEIGHT = CANVAS_HEIGHT + PLAY_CONTROLS_HEIGHT + (BORDER_THICKNESS * 2)
SIDE_PANE_DELETE_MARGIN = 32
PARTICLE_LIST_X = BORDER_THICKNESS
PARTICLE_LIST_Y = SIDE_PANE_Y + SIDE_PANE_HEIGHT + (BORDER_THICKNESS * 2)
@@ -82,6 +82,9 @@ class AnimationEditor
"Swamp", "SwampOpp", "Toxic", "UseItem", "WideGuard",
"Wrap"
]
DELETABLE_COMMAND_PANE_PROPERTIES = [
:x, :y, :z, :frame, :visible, :opacity, :zoom_x, :zoom_y, :angle, :flip, :blending
]
def initialize(anim_id, anim)
load_settings
@@ -113,7 +116,8 @@ class AnimationEditor
@components[:canvas] = AnimationEditor::Canvas.new(@canvas_viewport, @anim, @settings)
# Side panes
[:commands_pane, :se_pane, :particle_pane, :keyframe_pane].each do |pane|
@components[pane] = UIControls::ControlsContainer.new(SIDE_PANE_X, SIDE_PANE_Y, SIDE_PANE_WIDTH, SIDE_PANE_HEIGHT)
@components[pane] = UIControls::ControlsContainer.new(SIDE_PANE_X, SIDE_PANE_Y, SIDE_PANE_WIDTH, SIDE_PANE_HEIGHT,
(pane == :commands_pane) ? SIDE_PANE_DELETE_MARGIN : 0)
end
# TODO: Make a side pane for colour/tone editor (accessed from
# @components[:commands_pane] via a button; has Apply/Cancel buttons
@@ -228,6 +232,27 @@ class AnimationEditor
# commands_pane.add_labelled_button(:masking, _INTL("Masking"), _INTL("Edit"))
# TODO: Add buttons that shift all commands from the current keyframe and
# later forwards/backwards in time?
# Add all "delete" buttons
delete_bitmap = Bitmap.new(16, 16)
delete_disabled_bitmap = Bitmap.new(16, 16)
14.times do |i|
case i
when 0, 13 then wid = 3
when 1, 12 then wid = 4
else wid = 5
end
delete_bitmap.fill_rect([i - 1, 1].max, i + 1, wid, 1, Color.red)
delete_bitmap.fill_rect([i - 1, 1].max, 14 - i, wid, 1, Color.red)
delete_disabled_bitmap.fill_rect([i - 1, 1].max, i + 1, wid, 1, Color.new(160, 160, 160))
delete_disabled_bitmap.fill_rect([i - 1, 1].max, 14 - i, wid, 1, Color.new(160, 160, 160))
end
DELETABLE_COMMAND_PANE_PROPERTIES.each do |property|
parent = commands_pane.get_control(property)
btn = UIControls::BitmapButton.new(parent.x + parent.width + 6, parent.y + 2,
commands_pane.viewport, delete_bitmap, delete_disabled_bitmap)
btn.set_interactive_rects
commands_pane.controls.push([(property.to_s + "_delete").to_sym, btn])
end
end
def set_se_pane_contents
@@ -260,8 +285,10 @@ class AnimationEditor
particle_pane.add_labelled_dropdown_list(:focus, _INTL("Focus"), {}, :undefined)
# FlipIfFoe
# RotateIfFoe
# Delete button (if not "User"/"Target"/"SE")
# Duplicate button
particle_pane.add_button(:duplicate, _INTL("Duplicate this particle"))
# Delete button (if not "User"/"Target"/"SE")
particle_pane.add_button(:delete, _INTL("Delete this particle"))
# Shift all command timings by X keyframes (text box and button)
# Move particle up/down the list?
end
@@ -308,6 +335,8 @@ class AnimationEditor
# Create filepath controls
# TODO: Have two TextBoxes, one for folder and one for filename?
anim_properties.add_labelled_text_box(:pbs_path, _INTL("PBS filepath"), "")
# Create "involves a user" control
anim_properties.add_labelled_checkbox(:has_user, _INTL("Involves a user?"), true)
# Create "involves a target" control
anim_properties.add_labelled_checkbox(:has_target, _INTL("Involves a target?"), true)
# Create flags control
@@ -499,6 +528,14 @@ class AnimationEditor
else
component.get_control(:frame).enable
end
# Enable/disable property delete buttons
DELETABLE_COMMAND_PANE_PROPERTIES.each do |property|
if AnimationEditor::ParticleDataHelper.has_command_at?(@anim[:particles][particle_index], property, keyframe)
component.get_control((property.to_s + "_delete").to_sym).enable
else
component.get_control((property.to_s + "_delete").to_sym).disable
end
end
when :se_pane
se_particle = @anim[:particles].select { |p| p[:name] == "SE" }[0]
kyfrm = keyframe
@@ -554,32 +591,39 @@ class AnimationEditor
component.get_control(:graphic).enable
component.get_control(:focus).enable
end
# Set the possible foci depending on whether the animation involves a
# target
# TODO: Also filter for user/no user if implemented.
if @anim[:no_target]
component.get_control(:focus).values = {
:foreground => _INTL("Foreground"),
:midground => _INTL("Midground"),
:background => _INTL("Background"),
:user => _INTL("User"),
:user_side_foreground => _INTL("In front of user's side"),
:user_side_background => _INTL("Behind user's side")
}
# Enable/disable the Duplicate button
if ["SE"].include?(@anim[:particles][particle_index][:name])
component.get_control(:duplicate).disable
else
component.get_control(:focus).values = {
:foreground => _INTL("Foreground"),
:midground => _INTL("Midground"),
:background => _INTL("Background"),
:user => _INTL("User"),
:target => _INTL("Target"),
:user_and_target => _INTL("User and target"),
:user_side_foreground => _INTL("In front of user's side"),
:user_side_background => _INTL("Behind user's side"),
:target_side_foreground => _INTL("In front of target's side"),
:target_side_background => _INTL("Behind target's side")
}
component.get_control(:duplicate).enable
end
# Enable/disable the Delete button
if ["User", "Target", "SE"].include?(@anim[:particles][particle_index][:name])
component.get_control(:delete).disable
else
component.get_control(:delete).enable
end
# Set the possible foci depending on whether the animation involves a user
# and target
focus_values = {
:foreground => _INTL("Foreground"),
:midground => _INTL("Midground"),
:background => _INTL("Background"),
:user => _INTL("User"),
:target => _INTL("Target"),
:user_and_target => _INTL("User and target"),
:user_side_foreground => _INTL("In front of user's side"),
:user_side_background => _INTL("Behind user's side"),
:target_side_foreground => _INTL("In front of target's side"),
:target_side_background => _INTL("Behind target's side")
}
if @anim[:no_user]
GameData::Animation::FOCUS_TYPES_WITH_USER.each { |f| focus_values.delete(f) }
end
if @anim[:no_target]
GameData::Animation::FOCUS_TYPES_WITH_TARGET.each { |f| focus_values.delete(f) }
end
component.get_control(:focus).values = focus_values
when :particle_list
# Disable the "move particle up/down" buttons if the selected particle
# can't move that way (or there is no selected particle)
@@ -647,11 +691,17 @@ class AnimationEditor
echoln "Color/Tone button clicked"
else
particle = @anim[:particles][particle_index]
new_cmds = AnimationEditor::ParticleDataHelper.add_command(particle, property, keyframe, value)
if new_cmds
particle[property] = new_cmds
prop = property
if property.to_s[/_delete$/]
prop = property.to_s.sub(/_delete$/, "").to_sym
new_cmds = AnimationEditor::ParticleDataHelper.delete_command(particle, prop, keyframe)
else
particle.delete(property)
new_cmds = AnimationEditor::ParticleDataHelper.add_command(particle, property, keyframe, value)
end
if new_cmds
particle[prop] = new_cmds
else
particle.delete(prop)
end
@components[:particle_list].change_particle_commands(particle_index)
@components[:play_controls].duration = @components[:particle_list].duration
@@ -707,6 +757,18 @@ class AnimationEditor
refresh_component(:particle_pane)
refresh_component(:canvas)
end
when :duplicate
AnimationEditor::ParticleDataHelper.duplicate_particle(@anim[:particles], particle_index)
@components[:particle_list].set_particles(@anim[:particles])
@components[:particle_list].particle_index = particle_index + 1
refresh
when :delete
if confirm_message(_INTL("Are you sure you want to delete this particle?"))
AnimationEditor::ParticleDataHelper.delete_particle(@anim[:particles], particle_index)
@components[:particle_list].set_particles(@anim[:particles])
@components[:particle_list].keyframe = 0 if @anim[:particles][particle_index][:name] == "SE"
refresh
end
else
particle = @anim[:particles][particle_index]
new_cmds = AnimationEditor::ParticleDataHelper.set_property(particle, property, value)
@@ -762,10 +824,17 @@ class AnimationEditor
when :pbs_path
txt = value.gsub!(/\.txt$/, "")
@anim[property] = txt
when :has_user
@anim[:no_user] = !value
# TODO: Add/delete the "User" particle accordingly, and change the foci
# of any other particle involving a user. Then refresh a lot of
# components.
refresh_component(:canvas)
when :has_target
@anim[:no_target] = !value
# TODO: Add/delete the "Target" particle accordingly. Then refresh a lot
# of components.
# TODO: Add/delete the "Target" particle accordingly, and change the
# foci of any other particle involving a target. Then refresh a
# lot of components.
refresh_component(:canvas)
when :usable
@anim[:ignore] = !value

View File

@@ -96,6 +96,7 @@ class AnimationEditor
anim_properties.get_control(:version).value = @anim[:version] || 0
anim_properties.get_control(:name).value = @anim[:name] || ""
anim_properties.get_control(:pbs_path).value = (@anim[:pbs_path] || "unsorted") + ".txt"
anim_properties.get_control(:has_user).value = !@anim[:no_user]
anim_properties.get_control(:has_target).value = !@anim[:no_target]
anim_properties.get_control(:usable).value = !(@anim[:ignore] || false)
# TODO: Populate flags.

View File

@@ -167,6 +167,10 @@ module AnimationEditor::ParticleDataHelper
particle[property] = value
end
def has_command_at?(particle, property, frame)
return particle[property]&.any? { |cmd| (cmd[0] == frame) || (cmd[0] + cmd[1] == frame) }
end
def add_command(particle, property, frame, value)
# Split particle[property] into values and interpolation arrays
set_points = [] # All SetXYZ commands (the values thereof)
@@ -244,6 +248,44 @@ module AnimationEditor::ParticleDataHelper
return (ret.empty?) ? nil : ret
end
# Cases:
# * SetXYZ - delete it
# * MoveXYZ start - turn into a SetXYZ at the end point
# * MoveXYZ end - delete it (this may happen to remove the start diamond too)
# * MoveXYZ end and start - merge both together (use first's type)
# * SetXYZ and MoveXYZ start - delete SetXYZ (leave MoveXYZ alone)
# * SetXYZ and MoveXYZ end - (unlikely) delete both
# * SetXYZ and MoveXYZ start and end - (unlikely) delete SetXYZ, merge Moves together
def delete_command(particle, property, frame)
# Find all relevant commands
set_now = nil
move_ending_now = nil
move_starting_now = nil
particle[property].each do |cmd|
if cmd[1] == 0
set_now = cmd if cmd[0] == frame
else
move_starting_now = cmd if cmd[0] == frame
move_ending_now = cmd if cmd[0] + cmd[1] == frame
end
end
# Delete SetXYZ if it is at frame
particle[property].delete(set_now) if set_now
# Edit/delete MoveXYZ commands starting/ending at frame
if move_ending_now && move_starting_now # Merge both MoveXYZ commands
move_ending_now[1] += move_starting_now[1]
particle[property].delete(move_starting_now)
elsif move_ending_now # Delete MoveXYZ ending now
particle[property].delete(move_ending_now)
elsif move_starting_now && !set_now # Turn into SetXYZ at its end point
move_starting_now[0] += move_starting_now[1]
move_starting_now[1] = 0
move_starting_now[3] = nil
move_starting_now.compact!
end
return (particle[property].empty?) ? nil : particle[property]
end
#-----------------------------------------------------------------------------
def get_se_display_text(property, value)
@@ -336,7 +378,29 @@ module AnimationEditor::ParticleDataHelper
particles.insert(index, new_particle)
end
# Copies the particle at index and inserts the copy immediately after that
# index.
def duplicate_particle(particles, index)
new_particle = {}
particles[index].each_pair do |key, value|
if value.is_a?(Array)
new_particle[key] = []
value.each { |cmd| new_particle[key].push(cmd.clone) }
else
new_particle[key] = value.clone
end
end
new_particle[:name] += " (copy)"
particles.insert(index + 1, new_particle)
end
def swap_particles(particles, index1, index2)
particles[index1], particles[index2] = particles[index2], particles[index1]
end
# Deletes the particle at the given index
def delete_particle(particles, index)
particles[index] = nil
particles.compact!
end
end

View File

@@ -430,7 +430,7 @@ class AnimationEditor::Canvas < Sprite
end
@anim[:particles].each_with_index do |particle, i|
if GameData::Animation::FOCUS_TYPES_WITH_TARGET.include?(particle[:focus])
refresh_particle(i)
refresh_particle(i) # Because there can be multiple targets
else
refresh_sprite(i) if particle[:name] != "SE"
end

View File

@@ -7,7 +7,7 @@ class AnimationEditor::ParticleList < UIControls::BaseControl
TIMELINE_HEIGHT = 24 - VIEWPORT_SPACING
LIST_X = 0
LIST_Y = TIMELINE_HEIGHT + VIEWPORT_SPACING
LIST_WIDTH = 150 - VIEWPORT_SPACING
LIST_WIDTH = 180 - VIEWPORT_SPACING
COMMANDS_X = LIST_WIDTH + VIEWPORT_SPACING
COMMANDS_Y = LIST_Y
@@ -83,7 +83,7 @@ class AnimationEditor::ParticleList < UIControls::BaseControl
@particle_line_sprite.oy = @particle_line_sprite.height / 2
@particle_line_sprite.bitmap.fill_rect(0, 0, @particle_line_sprite.bitmap.width, @particle_line_sprite.bitmap.height, Color.red)
# Buttons and button bitmaps
initialze_button_bitmaps
initialize_button_bitmaps
@controls = []
add_particle_button = UIControls::BitmapButton.new(x + 1, y + 1, viewport, @add_button_bitmap)
add_particle_button.set_interactive_rects
@@ -113,7 +113,7 @@ class AnimationEditor::ParticleList < UIControls::BaseControl
@commands = {}
end
def initialze_button_bitmaps
def initialize_button_bitmaps
@add_button_bitmap = Bitmap.new(12, 12)
@add_button_bitmap.fill_rect(1, 5, 10, 2, TEXT_COLOR)
@add_button_bitmap.fill_rect(5, 1, 2, 10, TEXT_COLOR)
@@ -529,9 +529,6 @@ class AnimationEditor::ParticleList < UIControls::BaseControl
end
end
# TODO: Add indicator that this is selected (if so). Some kind of arrow on the
# left, or a red horizontal line (like the keyframe's vertical line), or
# fill_rect with colour instead of outline_rect?
def refresh_particle_list_sprite(index)
spr = @list_sprites[index]
return if !spr