diff --git a/lua/pac3/core/client/base_part.lua b/lua/pac3/core/client/base_part.lua index 94d3df2a8..00149498a 100644 --- a/lua/pac3/core/client/base_part.lua +++ b/lua/pac3/core/client/base_part.lua @@ -1147,13 +1147,17 @@ do -- serializing for key, value in next, tbl.self do if key == "UniqueID" then goto CONTINUE end - if self["Set" .. key] then + -- security: only apply declared (storable) properties. Internal + -- setters (SetPlayerOwner, SetShowInEditor, SetOwner, ...) must + -- never be reachable from network-provided part data. + -- Legit outfits always contain StorableVars only (see ToTable). + if self.StorableVars[key] and self["Set" .. key] then if key == "Material" then table_insert(self.delayed_variables, {key = key, val = value}) end self["Set" .. key](self, value) elseif key ~= "ClassName" then - pac.dprint("settable: unhandled key [%q] = %q", key, tostring(value)) + pac.dprint("settable: ignored non-storable key [%q] = %q", key, tostring(value)) end ::CONTINUE:: diff --git a/lua/pac3/core/client/bones.lua b/lua/pac3/core/client/bones.lua index 71f3aab04..2787cf3de 100644 --- a/lua/pac3/core/client/bones.lua +++ b/lua/pac3/core/client/bones.lua @@ -407,7 +407,7 @@ do -- bone manipulation for boneanimlib if pac_boneanim then for i = 0, count do - ManipulateBoneScale(ent, i, SCALE_RESET) + ManipulateBoneScale(ent, i, (pac_boneanim.scales and pac_boneanim.scales[i]) or SCALE_RESET) ManipulateBonePosition(ent, i, pac_boneanim.positions[i] or ORIGIN_RESET) ManipulateBoneAngles(ent, i, pac_boneanim.angles[i] or ANGLE_RESET) ManipulateBoneJiggle(ent, i, 0) @@ -452,12 +452,14 @@ do -- bone manipulation for boneanimlib function pac.SetEntityBoneMatrix(ent, i, matrix) pac.ManipulateBonePosition(ent, i, matrix:GetTranslation()) pac.ManipulateBoneAngles(ent, i, matrix:GetAngles()) + pac.ManipulateBoneScale(ent, i, matrix:GetScale()) end function pac.ResetEntityBoneMatrix(ent, i) pac.ManipulateBoneAngles(ent, i, angle_zero) pac.ManipulateBonePosition(ent, i, vector_origin) + pac.ManipulateBoneScale(ent, i, SCALE_RESET) end function pac.ManipulateBonePosition(ply, id, var) @@ -479,6 +481,17 @@ do -- bone manipulation for boneanimlib ply:ManipulateBoneAngles(id, var) end end + + function pac.ManipulateBoneScale(ply, id, var) + ply.pac_boneanim = ply.pac_boneanim or {positions = {}, angles = {}} + ply.pac_boneanim.scales = ply.pac_boneanim.scales or {} + + ply.pac_boneanim.scales[id] = var + + if not ply.pac_has_parts then + ply:ManipulateBoneScale(id, var) + end + end end if Entity(1):IsPlayer() and not PAC_RESTART then diff --git a/lua/pac3/core/client/parts/event.lua b/lua/pac3/core/client/parts/event.lua index f10a18403..d9dcb5262 100644 --- a/lua/pac3/core/client/parts/event.lua +++ b/lua/pac3/core/client/parts/event.lua @@ -8,6 +8,14 @@ local Vector = Vector local util = util local SysTime = SysTime +-- localization helper +local function L(str) + if pace and pace.LanguageString then + return pace.LanguageString(str) + end + return str +end + local BUILDER, PART = pac.PartTemplate("base") @@ -452,7 +460,7 @@ function PART:GetDynamicProperties(reset_to_default) if udata.default then arg.set(udata.default) else - arg.set(nil) + arg.set(get_default(typ)) end end end @@ -3337,18 +3345,31 @@ do return self.extra_nice_name(part, ent, part:GetParsedArgumentsForObject(self)) end - local str = part:GetEvent() + local str = L(pac.PrettifyName(part:GetEvent())) if part:GetArguments() ~= "" then - local args = part:GetArguments():gsub(";", " or ") + local args = {part:GetParsedArgumentsForObject(self)} + local labeled = {} + for pos, argData in ipairs(self:GetArguments()) do + local val = args[pos] + if val ~= nil then + if isstring(val) then + val = val:gsub(";", " or ") + end + labeled[#labeled + 1] = L(pac.PrettifyName(argData[1])) .. " " .. tostring(val) + end + end - if not tonumber(args) then - args = [["]] .. args .. [["]] + if #labeled > 0 then + local operator = part:GetOperator() + if operator ~= "" then + str = str .. " " .. L(operator) + end + str = str .. " " .. table.concat(labeled, ", ") end - str = str .. " " .. part:GetOperator() .. " " .. args end - return pac.PrettifyName(str) + return str end local eventMetaTable = { @@ -3454,6 +3475,28 @@ do end) end +-- helpers shared by the custom animation events +local function get_registered_animation_data(anim_id) + return pac.animations.GetRegisteredAnimations()[anim_id] +end + +local function get_animation_duration(anim_id) + local data = get_registered_animation_data(anim_id) + if not data or not data.FrameData then return 0 end + + local total = 0 + for i = 1, #data.FrameData do + total = total + (1 / (data.FrameData[i].FrameRate or 1)) + end + return total +end + +local function get_animation_frame_count(anim_id) + local data = get_registered_animation_data(anim_id) + if not data or not data.FrameData then return 1 end + return #data.FrameData +end + -- custom animation event do local animations = pac.animations @@ -3461,12 +3504,28 @@ do operator_type = "none", tutorial_explanation = "selecting a custom animation part via UID,\nthis event activates whenever the linked custom animation is currently playing somewhere between the frames specified", name = "custom_animation_frame", - nice = function(self, ent, animation) - if animation == "" then self:SetWarning("no animation selected") return "no animation" end + nice = function(self, ent, animation, frame_start, frame_end) + if animation == "" then self:SetWarning("no animation selected") return L("no animation") end local part = pac.GetLocalPart(animation) - if not IsValid(part) then self:SetError("invalid animation selected") return "invalid animation" end + if not IsValid(part) then self:SetError("invalid animation selected") return L("invalid animation selected") end self:SetWarning() - return part:GetName() + + local label = part:GetName() + local data = get_registered_animation_data(part:GetAnimID()) + local total = data and #data.FrameData or "--" + + local start = frame_start or 1 + if start < 1 then start = 1 end + local endf = frame_end and frame_end > 0 and frame_end or total + local window = start .. "-" .. tostring(endf) + + if ent:IsValid() then + local frame, delta = animations.GetEntityAnimationFrame(ent, part:GetAnimID()) + if frame and delta then + return label .. " [" .. L("frame") .. " " .. frame .. "/" .. total .. " | " .. window .. "]" + end + end + return label .. " [" .. L("frame") .. " --/" .. total .. " | " .. window .. "]" end, args = { {"animation", "string", {editor_panel = "custom_animation_frame"}}, @@ -3475,9 +3534,8 @@ do local anim = pace.current_part:GetProperty("animation") if anim ~= "" then local part = pac.GetLocalPart(anim) - -- GetAnimationDuration only works while editor is active for some reason - local data = util.JSONToTable(part:GetData()) - return math.Clamp(math.ceil(num), 1, #data.FrameData) + if not IsValid(part) then return end + return math.Clamp(math.ceil(num), 1, get_animation_frame_count(part:GetAnimID())) end end }}, @@ -3487,9 +3545,8 @@ do local start = pace.current_part:GetProperty("frame_start") if anim ~= "" then local part = pac.GetLocalPart(anim) - -- GetAnimationDuration only works while editor is active for some reason - local data = util.JSONToTable(part:GetData()) - return math.Clamp(math.ceil(num), start, #data.FrameData) + if not IsValid(part) then return end + return math.Clamp(math.ceil(num), start, get_animation_frame_count(part:GetAnimID())) end end }}, @@ -3511,6 +3568,11 @@ do if part.ClassName ~= "custom_animation" then return end local frame, delta = animations.GetEntityAnimationFrame(ent, part:GetAnimID()) if not frame or not delta then return end -- different animation part is playing + frame_start = math.max(frame_start, 1) -- frames are 1-based + -- frame_end stays 0 unless touched, so treat 0 as "until the animation ends" + if frame_end <= 0 then + frame_end = #animations.GetEntityAnimation(ent, part:GetAnimID()).FrameData + end return frame >= frame_start and frame <= frame_end end end @@ -3534,6 +3596,117 @@ do pac.RegisterEvent(eventObject) end +-- custom animation time event +do + local animations = pac.animations + local event = { + operator_type = "none", + tutorial_explanation = "selecting a custom animation part via UID,\nthis event activates whenever the linked custom animation's playback time is somewhere between the seconds specified,\nbased on the same time readout as the animation editor's timeline", + name = "custom_animation_time", + nice = function(self, ent, animation, time_start, time_end) + if animation == "" then self:SetWarning("no animation selected") return L("no animation") end + local part = pac.GetLocalPart(animation) + if not IsValid(part) then self:SetError("invalid animation selected") return L("invalid animation selected") end + self:SetWarning() + + local label = part:GetName() + local duration = get_animation_duration(part:GetAnimID()) + + local t_start = time_start or 0 + if t_start < 0 then t_start = 0 end + local t_end + if time_end and time_end > t_start then + t_end = time_end + elseif duration > 0 then + t_end = duration + else + t_end = time_end or 0 + end + local window = string.format("%.2f-%.2f%s", t_start, t_end, L("s")) + + if ent:IsValid() then + local frame, delta = animations.GetEntityAnimationFrame(ent, part:GetAnimID()) + if frame and delta then + local cycle = animations.GetEntityAnimationCycle(ent, part:GetAnimID()) + local duration_live = animations.GetAnimationDuration(ent, part:GetAnimID()) + local time = cycle ~= nil and duration_live and (cycle * duration_live) or 0 + return label .. " [" .. string.format("%.2f%s", time, L("s")) .. " | " .. window .. "]" + end + end + return label .. " [--.--" .. L("s") .. " | " .. window .. "]" + end, + args = { + {"animation", "string", {editor_panel = "custom_animation_frame"}}, + {"time_start", "number", { + editor_onchange = function(self, num) + local anim = pace.current_part:GetProperty("animation") + if anim ~= "" then + local part = pac.GetLocalPart(anim) + if not IsValid(part) then return end + return math.Clamp(num, 0, get_animation_duration(part:GetAnimID())) + end + end + }}, + {"time_end", "number", { + editor_onchange = function(self, num) + local anim = pace.current_part:GetProperty("animation") + local start = pace.current_part:GetProperty("time_start") + if anim ~= "" then + local part = pac.GetLocalPart(anim) + if not IsValid(part) then return end + return math.Clamp(num, start, get_animation_duration(part:GetAnimID())) + end + end + }} + }, + available = function(self, eventPart) + return next(animations.registered) and true or false + end, + func = function (self, eventPart, ent, animation, time_start, time_end) + local time_start = time_start or 0 + local time_end = time_end or 0 + if not animation or animation == "" then return end + if not IsValid(ent) then return end + if not next(animations.playing) then return end + for i,v in ipairs(animations.playing) do + if v == ent then + local part = pac.GetPartFromUniqueID(pac.Hash(ent), animation) + if not IsValid(part) then return end + if part.ClassName ~= "custom_animation" then return end + local frame, delta = animations.GetEntityAnimationFrame(ent, part:GetAnimID()) + if not frame or not delta then return end -- different animation part is playing + local cycle = animations.GetEntityAnimationCycle(ent, part:GetAnimID()) + if not cycle then return end + local duration = animations.GetAnimationDuration(ent, part:GetAnimID()) + -- time_end stays 0 unless touched, so treat any end that is not + -- after the start as "until the animation ends" + if time_end <= time_start then + time_end = duration + end + local time = cycle * duration + return time >= time_start and time <= time_end + end + end + end + } + + local eventObject = pac.CreateEvent(event.name, event.args) + eventObject.Think = event.func + eventObject.IsAvailable = event.available + eventObject.extra_nice_name = event.nice + + data = event + + local operator_type = data.operator_type + local preferred_operator = data.preferred_operator + local tutorial_explanation = data.tutorial_explanation + eventObject.operator_type = operator_type + eventObject.preferred_operator = preferred_operator + eventObject.tutorial_explanation = tutorial_explanation + + pac.RegisterEvent(eventObject) +end + -- DarkRP default events do local plyMeta = FindMetaTable('Player') diff --git a/lua/pac3/core/client/parts/model.lua b/lua/pac3/core/client/parts/model.lua index c6fa9c76c..9aee7fe73 100644 --- a/lua/pac3/core/client/parts/model.lua +++ b/lua/pac3/core/client/parts/model.lua @@ -800,10 +800,11 @@ function PART:ApplyMatrix() StandingHullHeight = self.StandingHullHeight, CrouchingHullHeight = self.CrouchingHullHeight, HullWidth = self.HullWidth, + OverrideStepSize = self.OverrideStepSize, + OverrideView = self.OverrideView, StepSize = self.StepSize, StandingViewOffset = self.StandingViewOffset, - CrouchingViewOffset = self.CrouchingViewOffset, - OverrideStepAndView = self.OverrideStepAndView + CrouchingViewOffset = self.CrouchingViewOffset }) if self.Size == 1 and self.Scale == vec_one then diff --git a/lua/pac3/core/client/parts/model/entity.lua b/lua/pac3/core/client/parts/model/entity.lua index 3a3b5de19..d9b66c8b0 100644 --- a/lua/pac3/core/client/parts/model/entity.lua +++ b/lua/pac3/core/client/parts/model/entity.lua @@ -22,15 +22,17 @@ BUILDER:StartStorableVars() :GetSet("NoDraw", false) :GetSet("DrawShadow", true) :GetSet("InverseKinematics", true) - :SetPropertyGroup("hull") :GetSet("StandingHullHeight", 72, {editor_panel = "hull"}) :GetSet("CrouchingHullHeight", 36, {editor_panel = "hull", crouch = true}) :GetSet("HullWidth", 32, {editor_panel = "hull"}) - :GetSet("OverrideStepAndView", false, {description = "When changing size, pac scales the step size and view offsets together. This overrides that."}) - :GetSet("StepSize", 18, {description = "Height that you can step up or down without jumping. Slightly tricky and may create view transition issues."}) - :GetSet("StandingViewOffset", 64, {description = "Eye height when standing"}) - :GetSet("CrouchingViewOffset", 28, {description = "Eye height when crouching"}) + :SetPropertyGroup("step size and view offset") + :GetSet("OverrideStepAndView", false, {hidden = true}) --legacy property, now split in two + :GetSet("OverrideStepSize", false, {description = "When changing size, pac scales the step size. This overrides that."}) + :GetSet("StepSize", 18, {description = "Height that you can step up or down without jumping. Slightly tricky and may create view transition issues.", editor_panel = "hull", step = true}) + :GetSet("OverrideView", false, {description = "When changing size, pac scales the view offsets. This overrides that."}) + :GetSet("StandingViewOffset", 64, {description = "Eye height when standing", editor_panel = "hull", view = true}) + :GetSet("CrouchingViewOffset", 28, {description = "Eye height when crouching", editor_panel = "hull", crouch = true, view = true}) :EndStorableVars() BUILDER:RemoveProperty("BoneMerge") @@ -62,6 +64,24 @@ function PART:SetHullWidth(val) self.HullWidth = val self:ApplyMatrix() end +function PART:SetOverrideView(b) + self.OverrideView = b + self:ApplyMatrix() +end +function PART:SetOverrideStepSize(b) + self.OverrideStepSize = b + self:ApplyMatrix() +end + +--backward compatibility +function PART:SetOverrideStepAndView(b) + if b == true then + self.OverrideStepSize = b + self.OverrideView = b + self:ApplyMatrix() + end +end + function PART:SetStandingViewOffset(val) self.StandingViewOffset = val self:ApplyMatrix() @@ -74,10 +94,6 @@ function PART:SetStepSize(val) self.StepSize = val self:ApplyMatrix() end -function PART:SetOverrideStepAndView(b) - self.OverrideStepAndView = b - self:ApplyMatrix() -end function PART:GetNiceName() local str = pac.PrettifyName(("/" .. self:GetModel()):match(".+/(.-)%.")) or self:GetModel() diff --git a/lua/pac3/core/client/parts/particles.lua b/lua/pac3/core/client/parts/particles.lua index 002a663af..24070d3b6 100644 --- a/lua/pac3/core/client/parts/particles.lua +++ b/lua/pac3/core/client/parts/particles.lua @@ -70,6 +70,18 @@ BUILDER:StartStorableVars() BUILDER:GetSet("Sliding", true) --BUILDER:GetSet("AddVelocityFromOwner", false) BUILDER:GetSet("OwnerVelocityMultiplier", 0) + BUILDER:SetPropertyGroup("particle function - EXPERIMENTAL") + BUILDER:GetSet("Animated", false) + BUILDER:GetSet("BrownianMotion", false) + BUILDER:GetSet("ThinkTime", 0) + BUILDER:GetSet("AnimatedParticleWrap", false, {description = "loop back to frame 1 when reaching max frame, otherwise stop there"}) + BUILDER:GetSet("AnimatedParticleStartFrame", 1) + BUILDER:GetSet("AnimatedParticleMaxFrame", 0) + BUILDER:GetSet("AnimatedParticleFps", 0) + BUILDER:GetSet("AnimatedTextureType", "$basetexture", {enums = function() return {["$basetexture"] = "$basetexture", ["$detail"] = "$detail", ["$normalmap"] = "$normalmap", ["$dudvmap"] = "$dudvmap"} end}) + BUILDER:GetSet("IndividualBrownianStrength", 0, {description = "Base chaos amount the particle starts with. NOT adjustable post-emission."}) + BUILDER:GetSet("SharedBrownianStrength", 0, {description = "All of the part's particles share this chaos amount. Adjustable post-emission."}) + BUILDER:GetSet("FlockBrownianStrength", 0, {description = "All of the part's particles share the same direction contributed by this factor to simulate one type of flock behavior. Adjustable post-emission."}) @@ -129,6 +141,55 @@ local function StickCallback(particle, hitpos, normal) particle:SetEndAlpha(particle.StickEndAlpha or 0) end +-- Think functions +local function brownian(particle) + particle:SetVelocity( + particle:GetVelocity() + + VectorRand(-particle.IndividualBrownianStrength,particle.IndividualBrownianStrength) + + VectorRand(-particle.part.SharedBrownianStrength,particle.part.SharedBrownianStrength) + + particle.part.correlated_brownian_vector + ) +end + +local expanded_mats_pool = {} + +local function spritecard2(particle) + if particle.mat_series == nil then return end + local chosen_frame = 0 + particle.next_frame = particle.next_frame or CurTime() + if CurTime() > particle.next_frame then + particle.frame = particle.frame + (particle.frame_per_second > 0 and 1 or -1) + + if particle.FrameWrap then + local max = #particle.mat_series + if particle.AnimatedParticleMaxFrame ~= 0 then max = particle.AnimatedParticleMaxFrame end + particle.clamp_frame = (particle.frame % max) + 1 + else + particle.clamp_frame = math.Clamp(particle.frame, 1, #particle.mat_series) + end + particle.next_frame = CurTime() + 1 / math.abs(particle.frame_per_second) + end + chosen_frame = math.Clamp(particle.clamp_frame or particle.frame, 1, #particle.mat_series) + + particle:SetMaterial(particle.mat_series[chosen_frame]) +end + +local function fading_inout_alpha(particle) + local alpha = 255*math.Clamp(-particle.birth + CurTime(),0,1)*math.Clamp(particle.maxlife - (-particle.birth + CurTime()),0,1) + particle:SetStartAlpha(alpha) particle:SetEndAlpha(alpha) +end + +local function unified_func(particle) + if particle.animated and particle.frame_per_second ~= 0 then + spritecard2(particle) + end + if particle.brownian then + brownian(particle) + end + + particle:SetNextThink(CurTime() + particle.thinktime) +end + function PART:GetEmitter() if not self.emitter then self.NextShot = 0 @@ -170,6 +231,7 @@ function PART:OnShow(from_rendering) end function PART:OnDraw() + self.correlated_brownian_vector = VectorRand(-self.FlockBrownianStrength, self.FlockBrownianStrength) self.number_particles = self.NumberParticles or 0 if not self.FireOnce then if self.Decay == 0 then @@ -227,6 +289,23 @@ function PART:SetAdditive(b) self:SetMaterial(self:GetMaterial()) end + +--CURRENT ISSUES WITH ANIMATED PARTICLES +--material creation is one-way and unlinked. Changes to pac materials won't be reflected after the particle samples the raw material +--currently, keyvalues aren't imported properly +local disclaimer = "BEWARE. The way to setup animated particles needs to be done BEFORE the particle samples your material when attempting to create the series of material copies in a ONE-WAY process.\n\n".. +"It doesn't transfer all the keyvalues correctly. And several shaders are unusable.\n\n".. +"1-Use UnlitGeneric or Refract. Other shaders are shown not to work. If you need a specific texture, create a pac material instead.\n".. +"2-If it's a pac material, create your material FIRST. And MAKE SURE it loads before the particle, using draw order and hierarchy.\n".. +"3-Make sure it has frames AND those frames match the texture type. The particle can control one type at a time\n\n".. +"Only if you understand this, you can run pac_prompt_animated_textures 0" + +function PART:SetAnimated(b) + self.Animated = b + if self.Animated then + self:SetWarning(disclaimer) + end +end function PART:SetMaterial(var) var = var or "" @@ -247,12 +326,31 @@ function PART:SetMaterial(var) self.Material = var end +--many shaders crash instantly +--or just not work properly +local allowed_shaders = { + ["sprite"] = true, + ["unlittwotexture"] = true, + ["unlitgeneric"] = true, + ["refract"] = true, + ["spritecard"] = true, + ["sprite_dx9"] = true, + ["refract_dx90"] = true, +} +local texture_frame_key = { + ["$basetexture"] = "$frame", + ["$normalmap"] = "$bumpframe", + ["$detail"] = "$detailframe", + ["$dudvmap"] = "$dudvframe" +} + + function PART:EmitParticles(pos, ang, real_ang) self.number_particles = self.number_particles or 0 if self.FireOnce and not self.FirstShot then self.CanKeepFiring = false end local emt = self:GetEmitter() if not emt then return end - + if self.NextShot < pac.RealTime and self.CanKeepFiring then if self.Material == "" then return end if self.Velocity == 500.01 then return end @@ -260,6 +358,10 @@ function PART:EmitParticles(pos, ang, real_ang) local originalAng = ang ang = ang:Forward() + local animated = self.Animated + local brownian = self.BrownianMotion + local thinkable = animated or brownian + local double = 1 if self.DoubleSided then double = 2 @@ -277,6 +379,11 @@ function PART:EmitParticles(pos, ang, real_ang) end end + expanded_mats_pool[self:GetPlayerOwner()] = expanded_mats_pool[self:GetPlayerOwner()] or {} + local owner_mats_pool = expanded_mats_pool[self:GetPlayerOwner()] or {} + local material = self.Materialm or self.Material + local basematerial = self.Materialm or self.Material + for _ = 1, math.min(self.number_particles,max) do local mats = self.Material:Split(";") if #mats > 1 then @@ -324,6 +431,65 @@ function PART:EmitParticles(pos, ang, real_ang) for i = 1, double do local particle = emt:Add(self.Materialm or self.Material, pos) + if thinkable then + if animated then + local matname = basematerial:GetName() + local kvs = basematerial:GetKeyValues() + local mats + --create material series + + if not owner_mats_pool[matname] or self.FireOnce then + if not allowed_shaders[basematerial:GetShader():lower()] then + self:SetError("Forbidden material! " .. basematerial:GetShader():lower() .. " shader will probably cause an instant crash.".. + "Only use one of the following shader types:" .. + "\n sprite\n unlittwotexture\n unlitgeneric\n refract\n spritecard\n sprite_dx9" + ) + return + end + + owner_mats_pool[matname] = {} + mats = owner_mats_pool[matname] + if basematerial:GetTexture(self.AnimatedTextureType) then + for i=1, basematerial:GetTexture(self.AnimatedTextureType):GetNumAnimationFrames(), 1 do + owner_mats_pool[matname][i] = pac.CreateMaterial(matname .. "_" .. i, basematerial:GetShader(), kvs) + for _,texture_type in ipairs({"$basetexture", "$normalmap", "$detail", "$dudvmap"}) do + owner_mats_pool[matname][i]:SetTexture(texture_type, basematerial:GetTexture(texture_type)) + owner_mats_pool[matname][i]:SetInt( + texture_frame_key[texture_type], + self.AnimatedTextureType == texture_type and i or 1 + ) + end + owner_mats_pool[matname][i]:SetInt("$flags", basematerial:GetInt("$flags")) + end + end + + end + mats = owner_mats_pool[matname] + if not mats then self:SetError("Looks like the material might've failed to be created") continue end + + local sane_frame = math.Clamp(math.ceil(self.AnimatedParticleStartFrame),1,#mats) + if not mats[sane_frame] then self:SetError("Looks like the material might've failed to be selected") continue end + particle:SetMaterial(mats[sane_frame]) + + particle.frame = self.AnimatedParticleStartFrame + particle.mat_name = matname + particle.mat_series = mats + particle.frame_per_second = self.AnimatedParticleFps + particle.FrameWrap = self.AnimatedParticleWrap + particle.AnimatedParticleMaxFrame = self.AnimatedParticleMaxFrame + end + particle:SetNextThink(CurTime()) + if brownian then + particle.IndividualBrownianStrength = self.IndividualBrownianStrength + particle.FlockBrownianStrength = self.FlockBrownianStrength + end + particle.animated = animated + particle.brownian = brownian + particle.thinktime = self.ThinkTime + particle.part = self + particle:SetThinkFunction(unified_func) + end + if double == 2 then local ang_ diff --git a/lua/pac3/core/client/util.lua b/lua/pac3/core/client/util.lua index 82a930acf..0399626c4 100644 --- a/lua/pac3/core/client/util.lua +++ b/lua/pac3/core/client/util.lua @@ -582,3 +582,324 @@ end function pac.FlashlightDisable(b) pac.flashlight_disabled = b end + + +do + -- CCT: correlated color temperature + --[[ + credit for pseudocode : Tanner Helland, from equations plotted based on data from Mitchell Charity + credit for some keywords' reference values : Alcon Lighting, Westinghouse Lighting, XenonPro + ]] + pac.temperature_color_keywords_categorized = { + ["simplified temperatures"] = { + {["cold"] = 10000}, + {["cool"] = 6000}, + {["neutral"] = 4300}, + {["warm"] = 2000}, + {["hot"] = 1000}, + }, + + ["sunlight"] = { + {["daylight"] = 6200}, + {["sunset"] = 3200}, + {["sky"] = 10000}, + {["horizon"] = 5000}, + {["overcast"] = 5000}, + }, + + ["astronomical references"] = { + {["white dwarf"] = 20000}, + {["sun"] = 5800}, + {["red giant"] = 5000}, + {["neutron star"] = 40000}, + }, + + ["human spaces"] ={ + {["home"] = 2700}, + {["home workspace"] = 4000}, + {["executive"] = 3500}, + {["surgical"] = 5000}, + {["industrial"] = 7000}, + {["office"] = 4000}, + {["warehouse"] = 5000}, + }, + + ["variations of white"] = { + {["true white"] = 6600}, + {["bright white"] = 5300}, + {["neutral white"] = 4000}, + {["cool white"] = 7000}, + {["soft white"] = 3000}, + {["warm white"] = 2700}, + }, + + ["technologies"] = { + {["LCD"] = 9000}, + {["CRT"] = 7000}, + {["xenon"] = 6200}, + {["fluorescent"] = 4200}, + {["halogen"] = 3000}, + {["incandescent"] = 2500}, + {["candle"] = 1800}, + {["match"] = 1700}, + {["fire"] = 1500}, + {["heat"] = 1000}, + } + } + + --extra keywords + pac.temperature_color_keywords = { + ["white"] = 6600, + ["yellow"] = 4300, + ["golden yellow"] = 3000, + ["red"] = 900, + ["orange"] = 1400, + ["gold"] = 4000, + ["golden"] = 4000, + ["blue"] = 10000, + ["ice blue"] = 10000, + ["alpine white"] = 6000, + + ["candlelight"] = 1800, + ["candle light"] = 1800, + ["candle flame"] = 1800, + ["lcd"] = 9000, + ["crt"] = 7000, + ["cathode"] = 7000, + ["cathode ray"] = 7000, + ["flame"] = 2000, + + ["library"] = 2700, + ["friendly"] = 2700, + ["cozy"] = 2700, + ["inviting"] = 2700, + ["relaxing"] = 2500, + ["bedroom"] = 2700, + ["kitchen"] = 2800, + ["relax"] = 2500, + ["welcoming"] = 3000, + ["soft"] = 3500, + + ["sharp"] = 4100, + ["focus"] = 4100, + ["alert"] = 4100, + ["neutral"] = 4500, + ["clean"] = 4000, + ["bright"] = 5000, + ["crisp"] = 4100, + ["harsh"] = 7000, + ["intense"] = 7000, + ["stasis"] = 10000, + + ["hospital"] = 4000, + ["kitchen"] = 4000, + ["scientific"] = 6000, + ["laboratory"] = 6000, + ["lab"] = 6000, + ["stadium"] = 5000, + ["showroom"] = 5000, + + ["task lighting"] = 4000, + ["security lighting"] = 5000, + ["overhead lighting"] = 3000, + ["vanity"] = 3000, + ["chandelier"] = 2700, + ["table lamp"] = 2700, + ["floor lamp"] = 2700, + ["fog light"] = 3200, + ["fog lights"] = 3200, + ["car headlights"] = 5000, + ["headlights"] = 5000, + } + + for category,tbl in pairs(pac.temperature_color_keywords_categorized) do + for i,tbl2 in ipairs(tbl) do + local k,v = next(tbl2) + pac.temperature_color_keywords[k] = v + end + end + + local function convert_temperature(kelvins) + kelvins = kelvins / 100 + local r = 0 + if kelvins < 66 then + r = 255 + else + r = kelvins - 60 + r = math.Clamp(329.698727446 * (r ^ -0.1332047592),0,255) + end + + local g = 0 + if kelvins <= 66 then + g = kelvins + g = math.Clamp(99.4708025861 * math.log(g) - 161.1195681661, 0, 255) + else + g = kelvins - 60 + g = 288.1221695283 * (g ^ -0.0755148492) + g = math.Clamp(g, 0, 255) + end + + local b = 0 + if kelvins >= 66 then + b = 255 + else + if kelvins <= 19 then + b = 0 + else + b = kelvins - 10 + b = math.Clamp(138.5177312231 * math.log(b) - 305.0447927307, 0, 255) + end + end + return Color(r,g,b) + end + + function pac.TemperatureToColor(kelvins) + if not kelvins then return nil end + if isstring(kelvins) then + kelvins = string.lower(kelvins) + if isnumber(tonumber(kelvins)) then + return convert_temperature(tonumber(kelvins)), tonumber(kelvins) + elseif pac.temperature_color_keywords[kelvins] then + return convert_temperature(pac.temperature_color_keywords[kelvins]), pac.temperature_color_keywords[kelvins] + elseif kelvins == "help" then + pac.Message("Temperature to Color:") + print("categorized list, slightly compressed so the combo box can be a bit more compact:") + PrintTable(pac.temperature_color_keywords_categorized) + print("=================") + print("combined list:") + PrintTable(pac.temperature_color_keywords) + return nil + end + elseif isnumber(kelvins) then + return convert_temperature(kelvins), kelvins + end + return nil + end + + local collapsed_categories = {} + function pac.OpenTemperatureToColorMenu(part, key, callback) + pace.color_temperature_active_temp = 0 + if not part then part = pace.current_part end + if not key then + key = "Color" + if part and part.ClassName == "material_3d" or part.ClassName == "material_2d" then + key = "color2" + end + end + if not callback then + callback = function(str) + local color, temp = pac.TemperatureToColor(str) + if temp ~= nil then pace.color_temperature_active_temp = temp end + local vec = color + if part.ProperColorRange then + vec = Vector(color.r/255,color.g/255,color.b/255) + else + vec = Vector(color.r,color.g,color.b) + end + part:SetProperty(key, vec) + end + end + local frame = vgui.Create("DFrame") + frame:SetSize(600, 200) + frame:Center() frame:MakePopup() + frame:SetTitle("Correlated Color Temperature Explorer") + + local pnl = vgui.Create("DPanel", frame) + pnl:Dock(FILL) + + local combo = vgui.Create("DComboBox", pnl) + combo:SetSortItems(false) + combo:SetSize(300,20) combo:SetText("select references") + combo:SetPos(0, 10) + + local text = vgui.Create("DTextEntry", pnl) + text:SetSize(300,20) text:SetPlaceholderText("Temperature or keyword") + text:SetPos(300, 10) + + local function add_category(key) + local tbl = pac.temperature_color_keywords_categorized[key] + combo:AddSpacer() + combo:AddChoice(key, "collapse " .. key) + combo:AddSpacer() + if collapsed_categories[key] then return end + for i,tbl2 in ipairs(tbl) do + local k,v = next(tbl2) + combo:AddChoice(k, v) + end + combo:AddChoice("") + end + --I want this order + add_category("simplified temperatures") + add_category("variations of white") + add_category("human spaces") + add_category("sunlight") + add_category("technologies") + add_category("astronomical references") + + local last_str = "" + function text:OnEnter(str) + if last_str ~= str then + if str ~= nil and str ~= "" then + callback(str) + end + end + last_str = str + end + + function combo:OnSelect(index, op_text, data) + if data == nil then return end + if string.StartsWith(data, "collapse ") then + collapsed_categories[string.gsub(data, "collapse ", "")] = not collapsed_categories[string.gsub(data, "collapse ", "")] + combo:Clear() + add_category("simplified temperatures") + add_category("variations of white") + add_category("human spaces") + add_category("sunlight") + add_category("technologies") + add_category("astronomical references") + timer.Simple(0, function() combo:OpenMenu() end) + return + end + text:OnEnter(data) + text:SetText(op_text .. ": " .. data .. "K") + end + + local strip = vgui.Create("DPanel", frame) + strip:SetSize(500,50) strip:SetPos(50, 80) + local strip_sub = vgui.Create("DLabel", frame) + strip_sub:SetFont("TargetID") + strip_sub:SetSize(500,15) strip_sub:SetPos(50, 80 + 5 + 50) + strip_sub:SetTextColor(frame:GetSkin().Colours.Category.Line.Text) + strip_sub:SetText("Hover over the strip...") + local precalculated_pixel_tempcolors = {} + for i=1,500 do + precalculated_pixel_tempcolors[i] = pac.TemperatureToColor(math.Remap(i, 1, 500, 1000, 15000)) + end + strip.Paint = function(w,h) + for i=1,500 do + local clr = precalculated_pixel_tempcolors[i] + surface.SetDrawColor(clr) + surface.DrawRect(i-1,0,1,h) + end + if CurTime() * 4 % 2 > 1 then + surface.SetDrawColor(Color(0,0,0)) + else + surface.SetDrawColor(Color(255,255,255)) + end + surface.DrawRect(math.Remap(pace.color_temperature_active_temp,1000,15000,1,500),0,1,h) + end + strip.Think = function() + local x,y = strip:ScreenToLocal(input.GetCursorPos()) + local kelvin = math.floor(math.Remap(x, 1, 500, 1000, 15000)) + if strip:IsHovered() and precalculated_pixel_tempcolors[x] ~= nil then + pace.color_temperature_active_temp = kelvin + strip_sub:SetText(kelvin .. "K : " .. tostring(precalculated_pixel_tempcolors[x]) .. " - Click to apply") + strip_sub:SetTextColor(precalculated_pixel_tempcolors[x]) + if input.IsMouseDown(MOUSE_LEFT) then + text:SetValue(kelvin) + text:OnEnter(kelvin) + end + end + end + end +end diff --git a/lua/pac3/core/shared/entity_mutators/size.lua b/lua/pac3/core/shared/entity_mutators/size.lua index b3b3a39a5..f5cc0d830 100644 --- a/lua/pac3/core/shared/entity_mutators/size.lua +++ b/lua/pac3/core/shared/entity_mutators/size.lua @@ -8,16 +8,25 @@ local allow_viewoffset = CreateConVar("pac_modifier_viewoffset", "0", CLIENT and function MUTATOR:WriteArguments(multiplier, other) net.WriteFloat(multiplier) - if other and (other.StandingHullHeight ~= nil and other.CrouchingHullHeight ~= nil and other.HullWidth ~= nil and other.StandingHullHeight ~= nil and other.StepSize ~= nil and other.StandingViewOffset ~= nil and other.CrouchingViewOffset ~= nil) then + if other then net.WriteBool(true) net.WriteFloat(other.StandingHullHeight) net.WriteFloat(other.CrouchingHullHeight) net.WriteFloat(other.HullWidth) - if other.OverrideStepAndView and (allow_step:GetBool() or allow_viewoffset:GetBool()) then - net.WriteBool(true) - net.WriteFloat(other.StepSize) - net.WriteFloat(other.StandingViewOffset) - net.WriteFloat(other.CrouchingViewOffset) + if allow_step:GetBool() then + if other.OverrideStepSize ~= nil and other.StepSize ~= nil then + net.WriteBool(other.OverrideStepSize) + net.WriteFloat(other.StepSize) + end + else + net.WriteBool(false) + end + if allow_viewoffset:GetBool() then + if other.OverrideView ~= nil and other.StandingViewOffset ~= nil and other.CrouchingViewOffset ~= nil then + net.WriteBool(other.OverrideView) + net.WriteFloat(other.StandingViewOffset) + net.WriteFloat(other.CrouchingViewOffset) + end else net.WriteBool(false) end @@ -48,9 +57,12 @@ function MUTATOR:ReadArguments() other.StandingHullHeight = net.ReadFloat() other.CrouchingHullHeight = net.ReadFloat() other.HullWidth = net.ReadFloat() - other.OverrideStepAndView = net.ReadBool() - if other.OverrideStepAndView then + other.OverrideStepSize = net.ReadBool() + if allow_step:GetBool() then other.StepSize = net.ReadFloat() + end + other.OverrideView = net.ReadBool() + if allow_viewoffset:GetBool() then other.StandingViewOffset = net.ReadFloat() other.CrouchingViewOffset = net.ReadFloat() end @@ -79,7 +91,7 @@ end local functions = { "ViewOffset", "ViewOffsetDucked", - "StepSize", + "StepSize" } function MUTATOR:Mutate(multiplier, other, hidden_state) @@ -131,26 +143,29 @@ function MUTATOR:Mutate(multiplier, other, hidden_state) ent:SetHull(smin, smax) ent:SetHullDuck(cmin, cmax) - if other.OverrideStepAndView then - if ent.SetStepSize and allow_step:GetBool() then - ent:SetStepSize(math.max(other.StepSize,0)) + if other.OverrideStepSize then + local SSize = other.StepSize + if ent.SetStepSize then + ent:SetStepSize(math.max(SSize,0)) + end + end + if other.OverrideView then + local soffset = other.StandingViewOffset + local coffset = other.CrouchingViewOffset + if ent.SetViewOffset then + ent:SetViewOffset(Vector(0,0,soffset)) end - if allow_viewoffset:GetBool() then - local soffset = other.StandingViewOffset - if ent.SetViewOffset then - ent:SetViewOffset(Vector(0,0,soffset)) - end - local coffset = other.CrouchingViewOffset - if ent.SetViewOffsetDucked then - ent:SetViewOffsetDucked(Vector(0,0,coffset)) - end + if ent.SetViewOffsetDucked then + ent:SetViewOffsetDucked(Vector(0,0,coffset)) end end else + ent:ResetHull() + end end end -pac.emut.Register(MUTATOR) \ No newline at end of file +pac.emut.Register(MUTATOR) diff --git a/lua/pac3/editor/client/animation_timeline.lua b/lua/pac3/editor/client/animation_timeline.lua index 1bc589bc0..fe9742286 100644 --- a/lua/pac3/editor/client/animation_timeline.lua +++ b/lua/pac3/editor/client/animation_timeline.lua @@ -16,10 +16,20 @@ do Position = true, Angles = true, Bone = true, + Scale = true, } + BUILDER + :StartStorableVars() + :SetPropertyGroup("orientation") + :PropertyOrder("Bone") + :PropertyOrder("Position") + :PropertyOrder("Angles") + :GetSet("Scale", Vector(1,1,1), {editor_sensitivity = 0.25}) + :EndStorableVars() + function PART:GetParentOwner() - return self:GetOwner() + return self.Owner end function PART:GetBonePosition() @@ -111,16 +121,825 @@ function timeline.UpdateFrameData() data.RU = data.RU or 0 data.RF = data.RF or 0 + data.SX = data.SX or 0 + data.SY = data.SY or 0 + data.SZ = data.SZ or 0 + timeline.dummy_bone:SetPosition(Vector(data.MF, -data.MR, data.MU)) timeline.dummy_bone:SetAngles(Angle(data.RR, data.RU, data.RF)) + timeline.dummy_bone:SetScale(Vector(1 + data.SX, 1 + data.SY, 1 + data.SZ)) +end + +do -- changed bones in selected keyframe + local BONE_CHANNELS = { "MU", "MR", "MF", "RU", "RF", "RR", "SX", "SY", "SZ" } + local CHANNEL_LABELS = { MU = "up", MR = "right", MF = "forward", RR = "pitch", RU = "yaw", RF = "roll", SX = "scale x", SY = "scale y", SZ = "scale z" } + local ZERO_BONE = { MU = 0, MR = 0, MF = 0, RU = 0, RF = 0, RR = 0, SX = 0, SY = 0, SZ = 0 } + + -- A bone is "changed" in a keyframe if any of its channels differ from the + -- previous keyframe (frame 0 = all zeros). This matches how the animation + -- interpolates + function timeline.GetFrameChangedBones(frameIndex) + local data = timeline.data + local changed = {} + local keyed = {} + local frameKeyed = {} + + if not data or not data.FrameData then return changed, keyed, frameKeyed end + + local curFrame = data.FrameData[frameIndex] + if not curFrame then return changed, keyed, frameKeyed end + + local prevFrame = data.FrameData[frameIndex - 1] + local curBones = curFrame.BoneInfo or {} + + for boneReal in pairs(curBones) do + frameKeyed[boneReal] = true + end + + for i, frame in ipairs(data.FrameData) do + if frame and frame.BoneInfo then + for boneReal in pairs(frame.BoneInfo) do + keyed[boneReal] = true + end + end + end + + -- map raw bone names to friendly names for display + local realToFriendly = {} + local boneData = pac.GetModelBones(timeline.entity) + for friendly, v in pairs(boneData) do + if v.real then realToFriendly[v.real] = friendly end + end + + for boneReal, curInfo in pairs(curBones) do + local prevInfo = prevFrame and prevFrame.BoneInfo and prevFrame.BoneInfo[boneReal] or ZERO_BONE + + local channels = {} + for _, ch in ipairs(BONE_CHANNELS) do + local diff = (curInfo[ch] or 0) - (prevInfo[ch] or 0) + if diff ~= 0 then + channels[ch] = diff + end + end + + if next(channels) then + changed[boneReal] = { + friendly = realToFriendly[boneReal] or boneReal, + channels = channels, + } + end + end + + return changed, keyed, frameKeyed + end + + -- which channels belong to each property group + local CHANNEL_GROUPS = { + Position = {"MU", "MR", "MF"}, + Angles = {"RR", "RU", "RF"}, + Scale = {"SX", "SY", "SZ"}, + } + + function timeline.ResetBoneChannelsInFrame(boneReal, channels) + if not timeline.selected_keyframe or not timeline.data or not timeline.data.FrameData then return end + + local index = timeline.selected_keyframe:GetAnimationIndex() + local frame = timeline.data.FrameData[index] + if not frame then return end + + local prevFrame = timeline.data.FrameData[index - 1] + local prevInfo = prevFrame and prevFrame.BoneInfo and prevFrame.BoneInfo[boneReal] + + frame.BoneInfo = frame.BoneInfo or {} + local info = frame.BoneInfo[boneReal] + if not info then + -- bone isn't keyed in this frame yet: treat current values as the + -- previous frame's (hold), so the reset is a no-op that just keys it + info = prevInfo and table.Copy(prevInfo) or table.Copy(ZERO_BONE) + frame.BoneInfo[boneReal] = info + end + + for _, ch in ipairs(channels) do + if prevInfo then + info[ch] = prevInfo[ch] or 0 + else + info[ch] = 0 + end + end + + if timeline.selected_bone == boneReal then + timeline.UpdateFrameData() + + if pace.current_part == timeline.dummy_bone then + pace.PopulateProperties(timeline.dummy_bone) + end + end + + timeline.Save() + timeline.SyncPreview() + timeline.UpdateChangedBonesPanel() + pace.RecordUndoHistory() + end + + function timeline.ResetBoneInFrame(boneReal) + if not timeline.selected_keyframe or not timeline.data or not timeline.data.FrameData then return end + + local index = timeline.selected_keyframe:GetAnimationIndex() + local frame = timeline.data.FrameData[index] + if not frame then return end + + local prevFrame = timeline.data.FrameData[index - 1] + local prevInfo = prevFrame and prevFrame.BoneInfo and prevFrame.BoneInfo[boneReal] + + frame.BoneInfo = frame.BoneInfo or {} + frame.BoneInfo[boneReal] = prevInfo and table.Copy(prevInfo) or table.Copy(ZERO_BONE) + + if timeline.selected_bone == boneReal then + timeline.UpdateFrameData() + + if pace.current_part == timeline.dummy_bone then + pace.PopulateProperties(timeline.dummy_bone) + end + end + + timeline.Save() + timeline.SyncPreview() + timeline.UpdateChangedBonesPanel() + pace.RecordUndoHistory() + end + + -- Removes every trace of this bone from the whole animation (all frames). + function timeline.RemoveBoneFromAnimation(boneReal) + if not timeline.data or not timeline.data.FrameData then return end + + for _, frame in ipairs(timeline.data.FrameData) do + if frame.BoneInfo then + frame.BoneInfo[boneReal] = nil + end + end + + timeline.Save() + timeline.SyncPreview() + timeline.UpdateChangedBonesPanel() + pace.RecordUndoHistory() + end + + function timeline.AddBoneToFrame(friendly) + local boneData = pac.GetModelBones(timeline.entity) + local bone = friendly and boneData and boneData[friendly] + if not bone or not bone.real then return end + + if not timeline.selected_keyframe or not timeline.data or not timeline.data.FrameData then return end + + local frame = timeline.data.FrameData[timeline.selected_keyframe:GetAnimationIndex()] + if not frame then return end + + frame.BoneInfo = frame.BoneInfo or {} + if not frame.BoneInfo[bone.real] then + timeline.ResetBoneInFrame(bone.real) + pace.RecordUndoHistory() + end + + if timeline.dummy_bone and timeline.dummy_bone:IsValid() then + timeline.dummy_bone:SetBone(friendly) + timeline.EditBone() + end + end + + -- text content of a row, shared by the row painter and the width sizing + local function get_row_texts(info, state) + local name = info.friendly or "?" + + if state == "changed" and info.channels then + local parts = {} + for _, ch in ipairs(BONE_CHANNELS) do + local v = info.channels[ch] + if v then + parts[#parts + 1] = CHANNEL_LABELS[ch] .. " " .. string.format("%+.2f", v) + end + end + if #parts > 0 then + return name, table.concat(parts, ", ") + end + elseif state == "held" then + return name, L"unchanged" + end + + return name + end + + local function add_bone_row(parent, boneReal, info, state, alt) + local row = parent:Add("DPanel") + row:Dock(TOP) + row:DockMargin(1, 1, 1, 0) + row:SetTall(18) + row:SetCursor("hand") + + local remove = vgui.Create("DImageButton", row) + remove:SetImage("icon16/delete.png") + remove:SetTooltip(L"remove bone from the whole animation") + remove:SizeToContents() + remove:Dock(LEFT) + remove:DockMargin(2, 0, 2, 0) + remove.DoClick = function() + -- shift skips the confirmation and removes right away + if input.IsKeyDown(KEY_LSHIFT) or input.IsKeyDown(KEY_RSHIFT) then + timeline.RemoveBoneFromAnimation(boneReal) + return true + end + + Derma_Query( + L"remove this bone from all keyframes of the animation? (hold shift while clicking the button to skip this prompt)", + L"remove bone", + L"remove", function() timeline.RemoveBoneFromAnimation(boneReal) end, + L"cancel", function() end + ) + return true + end + + local changed_groups = {} + if state == "changed" and info.channels then + for group, channels in pairs(CHANNEL_GROUPS) do + for _, ch in ipairs(channels) do + if info.channels[ch] then + changed_groups[group] = true + break + end + end + end + end + + local changed_count = 0 + for _ in pairs(changed_groups) do + changed_count = changed_count + 1 + end + + local reset + if changed_count > 1 then + reset = vgui.Create("DImageButton", row) + reset:SetImage("icon16/arrow_undo.png") + reset:SetTooltip(L"reset bone in this frame") + reset:SizeToContents() + reset:Dock(RIGHT) + reset:DockMargin(0, 0, 2, 0) + reset.DoClick = function() + timeline.ResetBoneInFrame(boneReal) + return true + end + end + + local CHANNEL_BUTTON_COLORS = { + Position = Color(100, 200, 110), + Angles = Color(90, 140, 230), + Scale = Color(220, 90, 90), + } + local channel_buttons = {} + + for _, group in ipairs({"Scale", "Angles", "Position"}) do + if changed_groups[group] then + local btn = vgui.Create("DImageButton", row) + btn:SetImage("icon16/arrow_refresh_small.png") + btn:SetColor(CHANNEL_BUTTON_COLORS[group]) + btn:SetTooltip(L("reset " .. group:lower() .. " in this frame")) + btn:SizeToContents() + btn:Dock(RIGHT) + btn:DockMargin(0, 0, 1, 0) + btn.DoClick = function() + timeline.ResetBoneChannelsInFrame(boneReal, CHANNEL_GROUPS[group]) + return true + end + table.insert(channel_buttons, 1, btn) + end + end + + local lm, _, rm = remove:GetDockMargin() + local left_pad = remove:GetWide() + lm + rm + 4 + + -- space for whichever buttons actually exist + local buttons_w = 0 + if reset then + buttons_w = reset:GetWide() + 2 + end + for _, btn in ipairs(channel_buttons) do + local bl, _, br = btn:GetDockMargin() + buttons_w = buttons_w + btn:GetWide() + bl + br + end + + surface.SetFont(pace.CurrentFont) + local name_text, delta_text = get_row_texts(info, state) + local content_w = left_pad + surface.GetTextSize(name_text) + 10 + if delta_text then + content_w = content_w + surface.GetTextSize(delta_text) + end + row.ContentWidth = content_w + buttons_w + 14 -- gap between text and the right-docked buttons + + row.OnCursorEntered = function(s) s.Hovered = true end + row.OnCursorExited = function(s) s.Hovered = false end + + row.Paint = function(s, w, h) + local skin = s:GetSkin() + local selected = timeline.selected_bone == boneReal + + -- same paint the keyframes use, so rows match the timeline look + derma.SkinHook("Paint", "CategoryButton", s, w, h) + + if selected then + local c = skin.Colours.Category.Line.Button_Selected + surface.SetDrawColor(c.r, c.g, c.b, 250) + surface.DrawRect(0, 0, w, h) + elseif s.Hovered then + surface.SetDrawColor(0, 0, 0, 10) + surface.DrawRect(0, 0, w, h) + end + + local text_color = selected and Color(40, 40, 40, 255) or skin.Colours.Label.Dark + local dim_color = selected and Color(40, 40, 40, 255) or Color(120, 120, 120, 220) + + local name_text, delta_text = get_row_texts(info, state) + draw.SimpleText(name_text, pace.CurrentFont, left_pad, h / 2, state == "changed" and text_color or dim_color, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + + if delta_text then + surface.SetFont(pace.CurrentFont) + local nw = surface.GetTextSize(name_text) + local delta_color = state == "changed" and (selected and text_color or Color(40, 140, 70, 255)) or dim_color + draw.SimpleText(delta_text, pace.CurrentFont, left_pad + nw + 10, h / 2, delta_color, TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + end + end + + row.OnMousePressed = function(_, mc) + if mc == MOUSE_LEFT and timeline.dummy_bone and timeline.dummy_bone:IsValid() then + timeline.dummy_bone:SetBone(info.friendly or boneReal) + timeline.EditBone() + end + end + + return row + end + + local function add_hint(parent, text) + local hint = parent:Add("DPanel") + hint:Dock(TOP) + hint:SetTall(18) + hint.Paint = function(_, w, h) + draw.SimpleText(text, pace.CurrentFont, 4, h / 2, Color(100, 100, 100, 220), TEXT_ALIGN_LEFT, TEXT_ALIGN_CENTER) + end + return hint + end + + function timeline.UpdateChangedBonesPanel() + if timeline.bones_panel_queued then return end + timeline.bones_panel_queued = true + timer.Simple(0, function() + timeline.bones_panel_queued = nil + timeline.RebuildChangedBonesPanel() + end) + end + + function timeline.RebuildChangedBonesPanel() + local bf = timeline.bones_frame + if not bf or not bf:IsValid() then return end + + local list = bf.bones_list + local canvas = list:GetCanvas() + + local children = canvas:GetChildren() + for i = #children, 1, -1 do + children[i]:Remove() + end + + local frameIndex = timeline.selected_keyframe and timeline.selected_keyframe:GetAnimationIndex() or 0 + local changed, keyed, frameKeyed = timeline.GetFrameChangedBones(frameIndex) + + -- map raw bone names to friendly names for display + local realToFriendly = {} + local boneData = pac.GetModelBones(timeline.entity) + for friendly, v in pairs(boneData) do + if v.real then realToFriendly[v.real] = friendly end + end + local function fr(boneReal) return realToFriendly[boneReal] or boneReal end + + local rows = {} + for boneReal in pairs(frameKeyed) do + local ch = changed[boneReal] + rows[#rows + 1] = { + boneReal = boneReal, + friendly = fr(boneReal), + changed = ch ~= nil, + channels = ch and ch.channels or nil, + } + end + table.sort(rows, function(a, b) + if a.changed ~= b.changed then return a.changed end + return a.friendly < b.friendly + end) + + for i, row in ipairs(rows) do + add_bone_row(canvas, row.boneReal, row, row.changed and "changed" or "held", i % 2 == 0) + end + + if #rows == 0 then + add_hint(canvas, L"no bones keyed in this frame") + end + + -- bones that only exist in other frames' BoneInfo + local elsewhere = {} + for boneReal in pairs(keyed) do + if not frameKeyed[boneReal] then + elsewhere[#elsewhere + 1] = { boneReal = boneReal, friendly = fr(boneReal) } + end + end + table.sort(elsewhere, function(a, b) return a.friendly < b.friendly end) + + if #elsewhere > 0 then + add_hint(canvas, L"keyed in other frames:") + for _, row in ipairs(elsewhere) do + add_bone_row(canvas, row.boneReal, row, "elsewhere", false) + end + end + + -- auto-size the window width to the widest row's measured content + local maxw = 0 + for _, pnl in ipairs(canvas:GetChildren()) do + if pnl.ContentWidth and pnl.ContentWidth > maxw then + maxw = pnl.ContentWidth + end + end + + -- fit the window to the content + bf:SetTall(math.min(#canvas:GetChildren() * 18 + 68, 340)) + if maxw > 0 then + bf:SetWide(math.Clamp(maxw, 240, 600)) + end + bf:SetTitle(L"keyframe bones") + + -- follow the selected keyframe + if bf.last_frame_index ~= frameIndex then + bf.last_frame_index = frameIndex + local f = timeline.frame + if f and f:IsValid() then + local kf = timeline.selected_keyframe + local x = f.x + f.keyframe_scroll.x + if kf and kf:IsValid() then + x = x + kf.x + kf:GetWide() / 2 - bf:GetWide() / 2 + end + local y = f.y - bf:GetTall() - 4 + local min_x = 0 + if pace.Editor and pace.Editor:IsValid() then + min_x = pace.Editor.x + pace.Editor:GetWide() + end + bf:SetPos(math.Clamp(x, min_x, ScrW() - bf:GetWide()), math.Clamp(y, 0, ScrH() - bf:GetTall())) + end + end + + list:InvalidateLayout() + end + + function timeline.OpenBonesPanel() + if timeline.bones_frame and timeline.bones_frame:IsValid() then + timeline.bones_frame:Remove() + end + + timeline.show_bones = true + + local bf = vgui.Create("DFrame") + bf:SetSize(380, 240) + bf:SetTitle(L"keyframe bones") + bf:ShowCloseButton(true) + bf:SetDraggable(true) + bf:SetSizable(true) + bf:SetMinWidth(240) + bf:SetMinHeight(80) + bf:DockPadding(4, 24, 4, 4) + bf.last_frame_index = -1 + + bf.Paint = function(s, w, h) + derma.SkinHook("Paint", "ListBox", s, w, h) + s:GetSkin().tex.CategoryList.Header(0, 0, w, 22) + end + if IsValid(bf.lblTitle) then + bf.lblTitle:SetTextColor(Color(240, 240, 240, 255)) + end + + -- float above the pac editor, but don't steal keyboard focus + bf:MakePopup() + bf:SetKeyboardInputEnabled(false) + + local toolbar = vgui.Create("DPanel", bf) + toolbar:Dock(TOP) + toolbar:SetTall(18) + + local add_bone_btn = vgui.Create("DImageButton", toolbar) + add_bone_btn:SetImage("icon16/add.png") + add_bone_btn:SetTooltip(L"add a bone to this keyframe") + add_bone_btn:SizeToContents() + add_bone_btn:Dock(LEFT) + add_bone_btn:DockMargin(2, 1, 2, 1) + add_bone_btn.DoClick = function() + timeline.OpenBoneSelector() + end + + local add_bone_lbl = vgui.Create("DLabel", toolbar) + add_bone_lbl:SetText(L"add bone") + add_bone_lbl:SetFont(pace.CurrentFont) + add_bone_lbl:SetTextColor(Color(120, 120, 120, 220)) + add_bone_lbl:SizeToContents() + add_bone_lbl:Dock(LEFT) + add_bone_lbl:DockMargin(0, 1, 0, 1) + + bf.bones_list = vgui.Create("DScrollPanel", bf) + bf.bones_list:Dock(FILL) + + timeline.bones_frame = bf + + -- open above the timeline, near the selected keyframe + local f = timeline.frame + if f and f:IsValid() then + local x = f.x + f.keyframe_scroll.x + local kf = timeline.selected_keyframe + if kf and kf:IsValid() then + x = x + kf.x + kf:GetWide() / 2 - bf:GetWide() / 2 + end + local y = f.y - bf:GetTall() - 4 + local min_x = 0 + if pace.Editor and pace.Editor:IsValid() then + min_x = pace.Editor.x + pace.Editor:GetWide() + end + bf:SetPos(math.Clamp(x, min_x, ScrW() - bf:GetWide()), math.Clamp(y, 0, ScrH() - bf:GetTall())) + end + + timeline.UpdateChangedBonesPanel() + end + + function timeline.OpenBoneSelector() + if not timeline.entity or not timeline.entity:IsValid() then return end + if not timeline.selected_keyframe then return end + + local boneData = pac.GetModelBones(timeline.entity) + if not boneData then return end + + local bones = {} + for friendly, v in pairs(boneData) do + if v.real and not v.is_special and not v.is_attachment then + table.insert(bones, {friendly = friendly, real = v.real}) + end + end + table.sort(bones, function(a, b) return a.friendly < b.friendly end) + + pace.SafeRemoveSpecialPanel() + + local frame = vgui.Create("DFrame") + frame:SetTitle(L"add bone") + frame:SetSize(280, 300) + frame:Center() + frame:SetSizable(true) + + local list = vgui.Create("DListView", frame) + list:Dock(FILL) + list:SetMultiSelect(false) + list:AddColumn(L"name") + + local search = vgui.Create("DTextEntry", frame) + search:Dock(BOTTOM) + search:RequestFocus() + + local first_line + local building = false + + local function build(find) + building = true + list:Clear() + first_line = nil + if find then find = find:lower() end + for _, bone in ipairs(bones) do + if not find or find == "" or bone.friendly:lower():find(find, nil, true) then + local line = list:AddLine(bone.friendly) + line.bone = bone + if not first_line then + first_line = line + list:SelectItem(line) -- preselect so enter picks it + end + end + end + building = false + end + + search.OnTextChanged = function() build(search:GetValue()) end + search.OnEnter = function() + -- enter picks the first result + if first_line and first_line:IsValid() and first_line.bone then + timeline.AddBoneToFrame(first_line.bone.friendly) + frame:Remove() + end + end + + list.OnRowSelected = function(_, id, line) + if building then return end + if line.bone then + timeline.AddBoneToFrame(line.bone.friendly) + end + frame:Remove() + end + + frame:MakePopup() + build() + + pace.ActiveSpecialPanel = frame + + return frame + end + + local function draw_bone_marker(spos, size, r, g, b) + surface.SetDrawColor(r, g, b, 70) + surface.DrawRect(spos.x - size * 0.5, spos.y - size * 0.5, size, size) + + surface.SetDrawColor(r, g, b, 255) + surface.DrawOutlinedRect(spos.x - size * 0.5, spos.y - size * 0.5, size, size) + + surface.SetDrawColor(0, 0, 0, 255) + surface.DrawOutlinedRect(spos.x - size * 0.5 - 1, spos.y - size * 0.5 - 1, size + 2, size + 2) + end + + local function changed_bones_hudpaint() + if not timeline.editing then return end + if not timeline.show_changed_bones then return end + if not pace.Focused then return end -- input passthrough: hide editor overlays + if not timeline.entity or not timeline.entity:IsValid() then return end + + local kf = timeline.selected_keyframe + if not kf or not kf.IsValid or not kf:IsValid() then return end + + local frameIndex = kf.GetAnimationIndex and kf:GetAnimationIndex() + if not frameIndex then return end + + local changed = timeline.GetFrameChangedBones(frameIndex) + if not next(changed) then + timeline.bone_markers = nil + return + end + + local ent = timeline.entity + local markers = {} + + for boneReal, info in pairs(changed) do + local bone_id = ent:LookupBone(boneReal) + if bone_id and bone_id >= 0 then + local pos = pac.GetBonePosAng(ent, boneReal) + if pos then + local spos = pos:ToScreen() + if spos and spos.visible then + markers[boneReal] = { + x = spos.x, + y = spos.y, + friendly = info.friendly or boneReal, + } + + if boneReal == timeline.selected_bone then + draw_bone_marker(spos, 12 + math.sin(RealTime() * 4) * 3, 148, 67, 201) + else + draw_bone_marker(spos, 9, 255, 200, 60) + end + + draw.SimpleTextOutlined(info.friendly or boneReal, pace.CurrentFont, spos.x, spos.y + 10, Color(255, 255, 255, 255), TEXT_ALIGN_CENTER, TEXT_ALIGN_CENTER, 1, Color(0, 0, 0, 200)) + end + end + end + end + + timeline.bone_markers = markers + end + + timeline.changed_bones_hudpaint = changed_bones_hudpaint +end + +do -- mirror keyframe pose + -- this sucks, I think a better way would be getting the bind pose or whatever + local function mirror_bone_info(info, has_mirror) + local out = table.Copy(info) + if has_mirror then + out.RR = -(info.RR or 0) + out.RF = -(info.RF or 0) + else + out.MR = -(info.MR or 0) + out.RU = -(info.RU or 0) + out.RF = -(info.RF or 0) + end + return out + end + + local function mirror_bone_name(name, real_bones) + local function swap_word(word) + local lower = word:lower() + local replacement + if lower == "left" then replacement = "right" + elseif lower == "right" then replacement = "left" + elseif lower == "l" then replacement = "r" + elseif lower == "r" then replacement = "l" + else return end + + -- keep the word's casing style + if word == word:upper() then + replacement = replacement:upper() + elseif word:sub(1, 1) == word:sub(1, 1):upper() then + replacement = replacement:sub(1, 1):upper() .. replacement:sub(2) + end + + return replacement + end + + local candidates = {} + + candidates[#candidates + 1] = (name:gsub("%a+", swap_word)) + + for _, pair in ipairs({{"left", "right"}, {"Left", "Right"}, {"LEFT", "RIGHT"}}) do + candidates[#candidates + 1] = (name:gsub(pair[1], pair[2])) + candidates[#candidates + 1] = (name:gsub(pair[2], pair[1])) + end + + -- trailing letter ("upperarml" -> "upperarmr") + local stem, last = name:sub(1, -2), name:sub(-1) + if last:lower() == "l" then + candidates[#candidates + 1] = stem .. (last == "l" and "r" or "R") + elseif last:lower() == "r" then + candidates[#candidates + 1] = stem .. (last == "r" and "l" or "L") + end + + -- leading letter ("lthumb" -> "rthumb") + local head = name:sub(1, 1) + if head:lower() == "l" then + candidates[#candidates + 1] = (head == "l" and "r" or "R") .. name:sub(2) + elseif head:lower() == "r" then + candidates[#candidates + 1] = (head == "r" and "l" or "L") .. name:sub(2) + end + + for _, candidate in ipairs(candidates) do + if candidate ~= name and candidate ~= "" and real_bones[candidate] then + return candidate + end + end + + return nil -- no mirrored bone found + end + + function timeline.MirrorKeyframe() + if not timeline.selected_keyframe or not timeline.data or not timeline.data.FrameData then return end + + local frame = timeline.data.FrameData[timeline.selected_keyframe:GetAnimationIndex()] + if not frame then return end + + local boneData = pac.GetModelBones(timeline.entity) + if not boneData then return end + + local real_bones = {} + for _, v in pairs(boneData) do + if v.real and not v.is_attachment then + real_bones[v.real] = true + end + end + + local mirrored = {} + for boneReal, info in pairs(frame.BoneInfo or {}) do + local target = mirror_bone_name(boneReal, real_bones) or boneReal + mirrored[target] = mirror_bone_info(info, target ~= boneReal) + end + + frame.BoneInfo = mirrored + + -- select the mirrored counterpart of the selected bone + local sel = timeline.selected_bone + if sel and timeline.dummy_bone and timeline.dummy_bone:IsValid() then + local target = mirror_bone_name(sel, real_bones) + if target and target ~= sel then + for friendly, v in pairs(boneData) do + if v.real == target and not v.is_special and not v.is_attachment then + timeline.dummy_bone:SetBone(friendly) + break + end + end + end + end + + timeline.EditBone() + timeline.Save() + timeline.SyncPreview() + timeline.UpdateChangedBonesPanel() + pace.RecordUndoHistory() + end end function timeline.Reindex() timeline.frame:Clear() + + local keyframes = {} for i, v in ipairs(timeline.data.FrameData) do local keyframe = timeline.frame:AddKeyFrame(true) keyframe:SetFrameData(i, v) + keyframes[i] = keyframe end + + timeline.UpdateChangedBonesPanel() + timeline.frame:InvalidateLayout(true) + + return keyframes end function timeline.EditBone() @@ -151,8 +970,31 @@ function timeline.EditBone() check_tpose() end +local function frames_equal(a, b) + for k, v in pairs(a) do + local bv = b[k] + if istable(v) then + if not istable(bv) or not frames_equal(v, bv) then return false end + elseif bv ~= v then + return false + end + end + for k in pairs(b) do + if a[k] == nil then return false end + end + return true +end + function timeline.Load(data) - timeline.data = data + timeline.data = data and table.Copy(data) or nil + data = timeline.data + + local sel = timeline.selected_keyframe + local prev_index, prev_data + if sel and sel.IsValid and sel:IsValid() and sel.GetAnimationIndex and sel.GetData then + prev_index = sel:GetAnimationIndex() + prev_data = sel:GetData() + end if data and data.FrameData then animations.ConvertOldData(data) @@ -171,12 +1013,24 @@ function timeline.Load(data) timeline.animation_part:SetAnimationType(data.Type) timeline.frame:Clear() + local keyframes = {} for i, v in ipairs(data.FrameData) do local keyframe = timeline.frame:AddKeyFrame(true) keyframe:SetFrameData(i, v) + keyframes[i] = keyframe + end + + local select_index = prev_index or 1 + if prev_data and data.FrameData then + for i, frame in ipairs(data.FrameData) do + if frames_equal(frame, prev_data) then + select_index = i + break + end + end end - timeline.SelectKeyframe(timeline.frame.keyframe_scroll:GetCanvas():GetChildren()[1]) + timeline.SelectKeyframe(keyframes[math.Clamp(select_index, 1, #keyframes)]) else timeline.data = {FrameData = {}, Type = timeline.animation_type, Interpolation = timeline.interpolation} timeline.frame:Clear() @@ -185,21 +1039,55 @@ function timeline.Load(data) end timeline.UpdateFrameData() + + if timeline.frame and timeline.frame:IsValid() then + timeline.frame:InvalidateLayout(true) + end +end + +local function refresh_entity_anim(id) + local ent = timeline.entity + if not ent or not ent:IsValid() or not timeline.data then return end + + local anim = animations.GetEntityAnimation(ent, id) + if anim and anim.FrameData ~= timeline.data.FrameData then + anim.FrameData = timeline.data.FrameData + + if timeline.editing and timeline.selected_keyframe and timeline.selected_keyframe:IsValid() then + animations.SetEntityAnimationFrame(ent, id, timeline.selected_keyframe:GetAnimationIndex(), 1) + end + end +end + +function timeline.SyncPreview() + local ent = timeline.entity + if not ent or not ent:IsValid() or not timeline.animation_part then return end + + local anim = animations.GetEntityAnimation(ent, timeline.animation_part:GetAnimID()) + if anim and timeline.data and anim.FrameData ~= timeline.data.FrameData then + anim.FrameData = timeline.data.FrameData + end end function timeline.Save() - local data = table.Copy(timeline.data) local part = timeline.animation_part + + if part and part:IsValid() and part:GetURL() == "" then + part.Data = util.TableToJSON(timeline.data) + end + timer.Create("pace_timeline_save", 0.1, 1, function() - if part and part:IsValid() then - animations.RegisterAnimation(part:GetAnimID(), data) - if part:GetURL() ~= "" then - file.Write("pac3/__animations/" .. part:GetName() .. ".txt", util.TableToJSON(data)) - part:SetData("") - else - part.Data = util.TableToJSON(data) - timer.Create("pace_backup", 1, 1, function() pace.Backup() end) - end + if not (part and part:IsValid() and timeline.data) then return end + + local data = table.Copy(timeline.data) + animations.RegisterAnimation(part:GetAnimID(), data) + refresh_entity_anim(part:GetAnimID()) + + if part:GetURL() ~= "" then + file.Write("pac3/__animations/" .. part:GetName() .. ".txt", util.TableToJSON(data)) + part:SetData("") + else + timer.Create("pace_backup", 5, 1, function() pace.Backup() end) end end) end @@ -210,8 +1098,14 @@ function timeline.SelectKeyframe(keyframe) timeline.EditBone() timeline.Save() + -- make sure a preview instance exists on the entity (gesture animations + -- remove themselves once they finish playing) + animations.SetEntityAnimation(timeline.entity, timeline.animation_part:GetAnimID()) + timeline.SyncPreview() animations.SetEntityAnimationFrame(timeline.entity, timeline.animation_part:GetAnimID(), keyframe.AnimationKeyIndex, 1) timeline.frame:Pause() + + timeline.UpdateChangedBonesPanel() end function timeline.IsEditingBone() @@ -221,6 +1115,11 @@ end function timeline.Close() timeline.Save() + if timeline.bones_frame and timeline.bones_frame:IsValid() then + timeline.bones_frame:Remove() + end + timeline.bones_frame = nil + -- old animeditor behavior if timeline.animation_part:GetURL() ~= "" then file.Write("pac3/__animations/backups/previous_session_"..os.date("%m%d%y%H%M%S")..".txt", util.TableToJSON(timeline.data)) @@ -241,6 +1140,8 @@ function timeline.Close() pac.RemoveHook("pace_OnVariableChanged", "pac3_timeline") pac.RemoveHook("CalcMainActivity", "pac3_timeline") + pac.RemoveHook("HUDPaint", "pac3_timeline_bones") + pac.RemoveHook("GUIMousePressed", "pac3_timeline_bones_click") end function timeline.Open(part) @@ -250,11 +1151,18 @@ function timeline.Open(part) timeline.editing = false timeline.first_pass = true + if timeline.show_bones == nil then + timeline.show_bones = false + end timeline.editing = true timeline.animation_part = part timeline.entity = part:GetOwner() + if timeline.show_changed_bones == nil then + timeline.show_changed_bones = true + end + timeline.frame = vgui.Create("pac3_timeline") timeline.frame:SetSize(ScrW()-pace.Editor:GetWide(), 93) timeline.frame:SetPos(pace.Editor:GetWide(), ScrH()-timeline.frame:GetTall()) @@ -286,6 +1194,7 @@ function timeline.Open(part) end timer.Simple(0, function() timeline.EditBone() end) -- post variable changed? + timeline.UpdateChangedBonesPanel() else local data = timeline.selected_keyframe:GetData() data.BoneInfo = data.BoneInfo or {} @@ -299,6 +1208,10 @@ function timeline.Open(part) data.BoneInfo[timeline.selected_bone].RU = data.BoneInfo[timeline.selected_bone].RU or 0 data.BoneInfo[timeline.selected_bone].RF = data.BoneInfo[timeline.selected_bone].RF or 0 + data.BoneInfo[timeline.selected_bone].SX = data.BoneInfo[timeline.selected_bone].SX or 0 + data.BoneInfo[timeline.selected_bone].SY = data.BoneInfo[timeline.selected_bone].SY or 0 + data.BoneInfo[timeline.selected_bone].SZ = data.BoneInfo[timeline.selected_bone].SZ or 0 + if key == "Position" then data.BoneInfo[timeline.selected_bone].MF = val.x data.BoneInfo[timeline.selected_bone].MR = -val.y @@ -307,12 +1220,35 @@ function timeline.Open(part) data.BoneInfo[timeline.selected_bone].RR = val.p data.BoneInfo[timeline.selected_bone].RU = val.y data.BoneInfo[timeline.selected_bone].RF = val.r + elseif key == "Scale" then + -- scale is stored as a delta from 1, so 0 means unchanged + data.BoneInfo[timeline.selected_bone].SX = val.x - 1 + data.BoneInfo[timeline.selected_bone].SY = val.y - 1 + data.BoneInfo[timeline.selected_bone].SZ = val.z - 1 end end + timeline.Save() + timeline.SyncPreview() + timeline.UpdateChangedBonesPanel() + + if key == "Position" or key == "Angles" or key == "Scale" then + timer.Create("pace_timeline_undo", 0.25, 1, function() + pace.RecordUndoHistory() + end) + end elseif part == timeline.animation_part then if key == "Data" or key == "URL" then - timeline.Load(animations.GetRegisteredAnimations()[part:GetAnimID()]) + if timeline.editing and key == "Data" and isstring(val) and val ~= "" then + local restored = util.JSONToTable(val) + if restored and restored.FrameData then + timeline.Load(restored) + end + elseif timeline.editing then + timeline.Save() + elseif timeline.frame and timeline.frame:IsValid() then + timeline.Load(animations.GetRegisteredAnimations()[part:GetAnimID()]) + end elseif key == "AnimationType" then timeline.SetAnimationType(val) elseif key == "Interpolation" then @@ -327,8 +1263,48 @@ function timeline.Open(part) end end) + pac.AddHook("HUDPaint", "pac3_timeline_bones", timeline.changed_bones_hudpaint) + + pac.AddHook("GUIMousePressed", "pac3_timeline_bones_click", function(mc) + if mc ~= MOUSE_LEFT then return end + if not timeline.show_changed_bones then return end + if not pace.Focused then return end -- don't select bones while walking around + + local markers = timeline.bone_markers + if not markers or not next(markers) then return end + + local mx, my = input.GetCursorPos() + local best, best_dist + for bone, m in pairs(markers) do + if bone ~= timeline.selected_bone then + local dx, dy = mx - m.x, my - m.y + -- hit area: the marker square plus its label below + if math.abs(dx) <= 14 and dy >= -12 and dy <= 26 then + local dist = dx * dx + dy * dy + if not best_dist or dist < best_dist then + best, best_dist = m, dist + end + end + end + end + + if best and timeline.dummy_bone and timeline.dummy_bone:IsValid() then + timeline.dummy_bone:SetBone(best.friendly) + timeline.EditBone() + return true -- don't start a camera drag from this click + end + end) + timeline.Load(animations.GetRegisteredAnimations()[part:GetAnimID()]) + if timeline.show_bones then + timeline.OpenBonesPanel() + end + + -- base undo snapshot: the state the timeline was opened in, so the first + -- edit can be undone + pace.RecordUndoHistory() + pac.RemoveHook("CalcMainActivity", "pac3_timeline") timeline.Stop() @@ -337,8 +1313,12 @@ end local editing_part pac.AddHook("pace_OnPartSelected", "pac3_timeline", function(part) if part.ClassName == "timeline_dummy_bone" then return end + + if pace.undoing then return end + if part.ClassName == "custom_animation" then if timeline.editing then + if part == editing_part then return end timeline.Close() end timeline.Open(part) @@ -357,6 +1337,26 @@ pac.AddHook("pace_OnPartSelected", "pac3_timeline", function(part) end end) +-- hide the timeline UI as well +pac.AddHook("pace_OnToggleFocus", "pac3_timeline", function(show_editor) + if not timeline.editing then return end + + local killing = pace.Focused + local hiding = killing and not show_editor + + if timeline.frame and timeline.frame:IsValid() then + timeline.frame:SetVisible(not hiding) + end + + local bf = timeline.bones_frame + if bf and bf:IsValid() then + bf:SetVisible(not hiding) + + bf:SetMouseInputEnabled(not killing) + bf:SetKeyboardInputEnabled(not killing) + end +end) + do local TIMELINE = {} @@ -399,7 +1399,7 @@ do do local bottom = vgui.Create("DPanel", self) bottom:Dock(RIGHT) - bottom:SetWide(72) + bottom:SetWide(92) do -- time controls local controls = bottom:Add("DPanel") controls:SetWide(100) @@ -453,20 +1453,31 @@ do add:SetImage("icon16/add.png") add:SetTooltip(L"add keyframe") add:SizeToContents() - add.DoClick = function() timeline.SelectKeyframe(self:AddKeyFrame()) timeline.Save() end + add.DoClick = function() timeline.SelectKeyframe(self:AddKeyFrame()) timeline.Save() pace.RecordUndoHistory() end add:Dock(LEFT) add:SetDisabled(true) self.add_keyframe_button = add local bone = saveload:Add("DImageButton") bone:SetImage("icon16/connect.png") - bone:SetTooltip(L"edit bones") + bone:SetTooltip(L"edit the selected bone") bone:SizeToContents() bone:Dock(LEFT) bone.DoClick = function() timeline.EditBone() end + local bone_toggle = saveload:Add("DImageButton") + bone_toggle:SetImage("icon16/eye.png") + bone_toggle:SetTooltip(L"show changed bones") + bone_toggle:SizeToContents() + bone_toggle:Dock(LEFT) + bone_toggle.DoClick = function() + timeline.show_changed_bones = not timeline.show_changed_bones + bone_toggle:SetImage(timeline.show_changed_bones and "icon16/eye.png" or "icon16/cross.png") + bone_toggle:SetTooltip(timeline.show_changed_bones and L"show changed bones" or L"show changed bones (disabled)") + end + local save = saveload:Add("DImageButton") save:SetImage("icon16/disk.png") save:SetTooltip(L"save") @@ -688,6 +1699,15 @@ do function TIMELINE:Think() DFrame.Think(self) + -- a gesture animation plays once and is removed from the entity when done, + -- so flip the play button back to "play" when it's no longer on the entity + if self.playing and timeline.animation_part then + local anim = animations.GetEntityAnimation(timeline.entity, timeline.animation_part:GetAnimID()) + if not anim then + self.playing = false + end + end + if pace.Editor:GetPos() + pace.Editor:GetWide() / 2 < ScrW() / 2 then self:SetSize(ScrW()-(pace.Editor.x+pace.Editor:GetWide()), 93) self:SetPos(pace.Editor.x+pace.Editor:GetWide(), ScrH()-self:GetTall()) @@ -700,7 +1720,7 @@ do local noplay = false if focus and (focus:GetClassName() == "TextEntry" or focus:GetClassName() == "DTextEntry") then noplay = true end - if input.IsKeyDown(KEY_SPACE) and not noplay then + if input.IsKeyDown(KEY_SPACE) and not input.IsMouseDown(MOUSE_LEFT) and not noplay and pace.Focused then if not self.toggled then self:Toggle() self.toggled = true @@ -711,7 +1731,12 @@ do end function TIMELINE:Play() - animations.RegisterAnimation(timeline.animation_part:GetAnimID(), timeline.data) + -- register a copy so the zero-backfill in RegisterAnimation doesn't + -- mutate the data we're editing + local data = table.Copy(timeline.data) + animations.RegisterAnimation(timeline.animation_part:GetAnimID(), data) + refresh_entity_anim(timeline.animation_part:GetAnimID()) + animations.SetEntityAnimation(timeline.entity, timeline.animation_part:GetAnimID()) animations.GetEntityAnimation(timeline.entity, timeline.animation_part:GetAnimID()).Paused = false @@ -910,6 +1935,7 @@ do self:MouseCapture(false) self:SetCursor("sizewe") timeline.Save() + pace.RecordUndoHistory() elseif self.move then local panels = {} local frames = {} @@ -940,6 +1966,11 @@ do self.move = nil self.move_x = nil timeline.frame.moving = false + + -- the reorder rewrote timeline.data.FrameData above + timeline.Save() + timeline.SyncPreview() + pace.RecordUndoHistory() end end end @@ -972,7 +2003,7 @@ do Derma_StringRequest(L"question", L"how long should this frame be in seconds?", tostring(self:GetWide()/secondDistance), - function(str) self:SetLength(tonumber(str)) end, + function(str) self:SetLength(tonumber(str)) timeline.Save() pace.RecordUndoHistory() end, function() end, L"set length", L"cancel" ) @@ -982,12 +2013,17 @@ do Derma_StringRequest(L"question", L"multiply "..self:GetAnimationIndex().."'s length", "1.0", - function(str) self:SetLength(1/tonumber(str)) end, + function(str) self:SetLength(1/tonumber(str)) timeline.Save() pace.RecordUndoHistory() end, function() end, L"multiply length", L"cancel" ) end):SetImage("icon16/time_add.png") + menu:AddOption(L"edit bones", function() + timeline.SelectKeyframe(self) + timeline.OpenBonesPanel() + end):SetImage("icon16/application_view_list.png") + if not self:GetRestart() then menu:AddOption(L"set restart", function() for _, v in pairs(timeline.frame.keyframe_scroll:GetCanvas():GetChildren()) do @@ -995,11 +2031,15 @@ do end self:SetRestart(true) timeline.data.RestartFrame = self:GetAnimationIndex() + timeline.Save() + pace.RecordUndoHistory() end):SetImage("icon16/control_repeat_blue.png") else menu:AddOption(L"unset restart", function() self:SetRestart(false) - timeline.data.StartFrame = nil + timeline.data.RestartFrame = nil + timeline.Save() + pace.RecordUndoHistory() end):SetImage("icon16/control_repeat.png") end @@ -1010,11 +2050,15 @@ do end self:SetStart(true) timeline.data.StartFrame = self:GetAnimationIndex() + timeline.Save() + pace.RecordUndoHistory() end):SetImage("icon16/control_play_blue.png") else menu:AddOption(L"unset start", function() self:SetStart(false) timeline.data.StartFrame = nil + timeline.Save() + pace.RecordUndoHistory() end):SetImage("icon16/control_play.png") end @@ -1034,15 +2078,23 @@ do self:GetData().BoneInfo[i].RF = v.RF * -1 end timeline.UpdateFrameData() + timeline.Save() + timeline.SyncPreview() + pace.RecordUndoHistory() end):SetImage("icon16/control_rewind_blue.png") + menu:AddOption(L"mirror", function() + timeline.MirrorKeyframe() + end):SetImage("icon16/arrow_switch.png") + local function duplicateTo(index) local data = self:GetData(); table.insert(timeline.data.FrameData, index, table.Copy(data)) - timeline.Reindex() + local keyframes = timeline.Reindex() timer.Simple(0, function() - timeline.SelectKeyframe(timeline.frame.keyframe_scroll:GetCanvas():GetChildren()[index]) + timeline.SelectKeyframe(keyframes[math.Clamp(index, 1, #keyframes)]) + pace.RecordUndoHistory() end) end @@ -1076,6 +2128,8 @@ do local count = #timeline.frame.keyframe_scroll:GetCanvas():GetChildren() local offset = remove_i >= count and count - 1 or remove_i + 1 timeline.SelectKeyframe(timeline.frame.keyframe_scroll:GetCanvas():GetChildren()[offset]) + timeline.Save() + pace.RecordUndoHistory() end):SetImage("icon16/page_delete.png") menu:AddOption(L"set easing style", function() @@ -1144,13 +2198,15 @@ do self:GetData().EaseStyle = style self.estyle = style timeline.Save() + pace.RecordUndoHistory() end function KEYFRAME:RemoveEaseStyle() self:GetData().EaseStyle = nil self.estyle = nil timeline.Save() + pace.RecordUndoHistory() end vgui.Register("pac3_timeline_keyframe", KEYFRAME, "DPanel") -end \ No newline at end of file +end diff --git a/lua/pac3/editor/client/asset_browser.lua b/lua/pac3/editor/client/asset_browser.lua index 9ac82ba92..d9a3fb57b 100644 --- a/lua/pac3/editor/client/asset_browser.lua +++ b/lua/pac3/editor/client/asset_browser.lua @@ -1,10 +1,11 @@ -- based on starfall -CreateClientConVar("pac_asset_browser_close_on_select", "1") -CreateClientConVar("pac_asset_browser_remember_layout", "1") -CreateClientConVar("pac_asset_browser_extra_options", "1") -CreateClientConVar("pac_favorites_try_to_get_asset_series", "1") -CreateClientConVar("pac_favorites_try_to_build_asset_series", "0") +local close_on_select = CreateClientConVar("pac_asset_browser_close_on_select", "1", true) +local remember_layout = CreateClientConVar("pac_asset_browser_remember_layout", "1", true) +local extra_options = CreateClientConVar("pac_asset_browser_extra_options", "1", true) --asset series favoriting, sound quicklist build mode +local asset_series_get = CreateClientConVar("pac_favorites_try_to_get_asset_series", "1", true) --adds options for favoriting a series as a favorite +local favorites_menu_expansion = CreateClientConVar("pac_favorites_try_to_build_asset_series", "0", true) --related, but it's used in properties local preview_mats = CreateClientConVar("pac_asset_browser_preview_materials_on_hover", "1") +local multiframe_icons = CreateClientConVar("pac_asset_browser_texture_multiframe_indicator", "1", true) local function rebuild_bookmarks() pace.bookmarked_ressources = pace.bookmarked_ressources or {} @@ -133,11 +134,40 @@ end local L = pace.LanguageString +local quicklist_buildmode_sub_icons = {} --icon is the item's panel, path is the item's full file path, on_menu is a function that can extend the function local function install_click(icon, path, pattern, on_menu, pathid) local old = icon.OnMouseReleased icon.OnMouseReleased = function(_, code) if code == MOUSE_LEFT then + --asset quick list build mode only applies to sound (handled by separate function) and particles + if pace.model_browser.QuickListBuildMode then + if pace.current_part == pace.model_browser.quicklist_particle_part_selected and pace.current_part.ClassName == "particles" then + if pattern then + for _, pattern in ipairs(isstring(pattern) and {pattern} or pattern) do + local test = path:match(pattern) + if test then + path = test + break + end + end + end + pace.model_browser.mats = pace.model_browser.mats or "" + if pace.model_browser.mats ~= "" then + pace.model_browser.mats = pace.model_browser.mats .. ";" .. path + else + pace.model_browser.mats = path + end + pace.current_part.pace_properties["Material"]:SetValue(pace.model_browser.mats) + pace.current_part.pace_properties["Material"].OnValueChanged(pace.model_browser.mats) + local sub_icon = vgui.Create("DImage", icon) + table.insert(quicklist_buildmode_sub_icons, sub_icon) + sub_icon:SetImage("icon16/accept.png") + local size = icon:GetWide() / 8 + sub_icon:SetSize(size,size) sub_icon:SetPos(icon:GetWide() - size, icon:GetTall() - size) + return old(_, code) + end + end pace.model_browser_callback(path, pathid) elseif code == MOUSE_RIGHT then local menu = DermaMenu() @@ -162,8 +192,8 @@ local function install_click(icon, path, pattern, on_menu, pathid) elseif not pace.bookmarked_ressources[resource_type] then pace.SaveRessourceBookmarks() end - if GetConVar("pac_asset_browser_extra_options"):GetBool() and pace.bookmarked_ressources[resource_type] then - if GetConVar("pac_favorites_try_to_get_asset_series"):GetBool() then + if extra_options:GetBool() and pace.bookmarked_ressources[resource_type] then + if asset_series_get:GetBool() then if not table.HasValue(pace.bookmarked_ressources[resource_type], path) then menu:AddOption(L"add series to favorites", function() table.insert(pace.bookmarked_ressources[resource_type], path) @@ -188,6 +218,24 @@ local function install_click(icon, path, pattern, on_menu, pathid) end):SetImage("icon16/cross.png") end end + if extra_options:GetBool() then + if not pace.model_browser.QuickListBuildMode and pace.current_part.ClassName == "particles" then + local pnl = menu:AddOption("Enable Quick list build mode", function() + pace.model_browser.QuickListBuildMode = true + pace.model_browser.mats = "" + pace.model_browser.quicklist_particle_part_selected = pace.current_part + end) + pnl:SetTooltip("Left click will concatenate a new material to the part's list using semicolon notation.") + elseif pace.model_browser.QuickListBuildMode then + menu:AddOption("Disable Quick list build mode", function() + pace.model_browser.QuickListBuildMode = nil pace.model_browser.mats = "" + for i, icon in ipairs(quicklist_buildmode_sub_icons) do + if icon and IsValid(icon) then icon:Remove() end + end + quicklist_buildmode_sub_icons = {} + end) + end + end if on_menu then on_menu(menu) end menu:Open() @@ -344,6 +392,12 @@ local function create_material_icon(path, grid_panel) end ) + local texture_keys = { + "$basetexture", + "$normalmap", + "$detail", + "$dudvmap" + } function icon:SetupMaterial() local mat = Material(mat_path) local shader = mat:GetShader():lower() @@ -446,6 +500,23 @@ local function create_material_icon(path, grid_panel) pnl:Dock(FILL) pnl:SetImage(mat_path) end + + if multiframe_icons:GetBool() then + local multiframes = {} + for i,k in ipairs(texture_keys) do + if mat:GetTexture(k) and mat:GetTexture(k):GetNumAnimationFrames() > 1 then + table.insert(multiframes, k .. " frames : " .. mat:GetTexture(k):GetNumAnimationFrames()) + end + end + + if #multiframes > 0 then + local sub_icon = vgui.Create("DImage", icon) + sub_icon:SetImage("icon16/pictures.png") + local size = icon:GetWide() / 8 + sub_icon:SetSize(size,size) sub_icon:SetPos(icon:GetWide() - size, icon:GetTall() - size) + icon:SetTooltip(path .. "\n" .. table.concat(multiframes,"\n")) + end + end end install_click(icon, path, "^materials/(.+)%.vmt$", function(menu) @@ -495,7 +566,7 @@ local function create_material_icon(path, grid_panel) end) - + icon.Think = function() if not preview_mats:GetBool() then return end if not pace.model_browser_part_key then return end @@ -754,7 +825,7 @@ function pace.AssetBrowser(callback, browse_types_str, part_key) if callback(...) == false then return end - if GetConVar("pac_asset_browser_close_on_select"):GetBool() then + if close_on_select:GetBool() then pace.model_browser:SetVisible(false) end end @@ -774,7 +845,7 @@ function pace.AssetBrowser(callback, browse_types_str, part_key) frame.title = L"asset browser" .. " - " .. (browse_types_str:gsub(";", " ")) - if GetConVar("pac_asset_browser_remember_layout"):GetBool() then + if remember_layout:GetBool() then frame:SetCookieName("pac_asset_browser") end @@ -852,6 +923,7 @@ function pace.AssetBrowser(callback, browse_types_str, part_key) options_menu:AddCVar(L"remember layout", "pac_asset_browser_remember_layout", "1", "0") options_menu:AddCVar(L"additional right click options", "pac_asset_browser_extra_options", "1", "0") options_menu:AddCVar(L"preview materials when hovering", "pac_asset_browser_preview_materials_on_hover", "1", "0") + options_menu:AddCVar(L"indicate animated textures", "pac_asset_browser_texture_multiframe_indicator", "1", "0") options_menu:AddCVar(L"try to find asset series for saving favorites", "pac_favorites_try_to_get_asset_series", "1", "0") options_menu:AddCVar(L"try to build asset series in the editor", "pac_favorites_try_to_build_asset_series", "1", "0") @@ -993,10 +1065,10 @@ function pace.AssetBrowser(callback, browse_types_str, part_key) menu:AddOption(L"copy path", function() SetClipboardText(sound) end) - if GetConVar("pac_asset_browser_extra_options"):GetBool() then + if extra_options:GetBool() then pace.bookmarked_ressources["sound"] = pace.bookmarked_ressources["sound"] or {} local resource_type = "sound" - if GetConVar("pac_favorites_try_to_get_asset_series"):GetBool() then + if asset_series_get:GetBool() then --print(sound) local extension = string.GetExtensionFromFilename(sound) local base_name = string.gsub(sound, "%d+."..extension.."$", "") @@ -1018,6 +1090,10 @@ function pace.AssetBrowser(callback, browse_types_str, part_key) pace.SaveRessourceBookmarks() end):SetImage("icon16/star.png") + local preview_list, pnl = menu:AddSubMenu("preview series contents") pnl:SetImage("icon16/text_list_numbers.png") + for i=1,#series_results.all_paths do + preview_list:AddOption(series_results.all_paths[i]) + end else menu:AddOption(L"remove series from favorites", function() table.remove(pace.bookmarked_ressources[resource_type], table.KeyFromValue( pace.bookmarked_ressources[resource_type], series_str )) diff --git a/lua/pac3/editor/client/logic.lua b/lua/pac3/editor/client/logic.lua index 92026aca8..9577e2a8a 100644 --- a/lua/pac3/editor/client/logic.lua +++ b/lua/pac3/editor/client/logic.lua @@ -57,6 +57,9 @@ function pace.OnOpenEditor() pace.SetViewAngles(pac.LocalPlayer:EyeAngles()) pace.EnableView(true) + pace.Focused = true + pace.UnfocusedVisible = nil + if table.Count(pac.GetLocalParts()) == 0 then pace.Call("CreatePart", "group", L"my outfit") end diff --git a/lua/pac3/editor/client/panels/editor.lua b/lua/pac3/editor/client/panels/editor.lua index 4947f7865..dd25190cf 100644 --- a/lua/pac3/editor/client/panels/editor.lua +++ b/lua/pac3/editor/client/panels/editor.lua @@ -564,6 +564,7 @@ function pace.GainFocus(show_editor) if self.allowclick ~= false then self:MakePopup() pace.Focused = true + pace.UnfocusedVisible = nil timer.Remove("pac_editor_visibility") @@ -586,6 +587,7 @@ function pace.KillFocus(show_editor) self:SetKeyBoardInputEnabled(false) gui.EnableScreenClicker(false) pace.Focused = false + pace.UnfocusedVisible = show_editor == true if not show_editor then self:AlphaTo(0, fade_time, 0) diff --git a/lua/pac3/editor/client/panels/extra_properties.lua b/lua/pac3/editor/client/panels/extra_properties.lua index f332492df..60acd36aa 100644 --- a/lua/pac3/editor/client/panels/extra_properties.lua +++ b/lua/pac3/editor/client/panels/extra_properties.lua @@ -1348,40 +1348,103 @@ do -- hull PANEL.ClassName = "properties_hull" PANEL.Base = "pace_properties_number" + local hull_color = Color(255, 204, 51, 255) function PANEL:OnValueSet() local function stop() RunConsoleCommand("-duck") hook.Remove("PostDrawOpaqueRenderables", "pace_draw_hull") + hook.Remove("PostDrawOpaqueRenderables", "pace_draw_hull2") + hook.Remove("PostDrawOpaqueRenderables", "pace_draw_hull3") end local time = os.clock() + 3 - hook.Add("PostDrawOpaqueRenderables", "pace_draw_hull", function() + local udata = self.udata + if udata then + if udata.step then + if not self.first_time then self.first_time = true return end + hook.Add("PostDrawOpaqueRenderables", "pace_draw_hull3", function() - if not pace.current_part:IsValid() then stop() return end - if pace.current_part.ClassName ~= "entity2" then stop() return end + if not pace.current_part:IsValid() then stop() return end + if pace.current_part.ClassName ~= "entity2" then stop() return end - local ent = pace.current_part:GetOwner() - if not ent.GetHull then stop() return end - if not ent.GetHullDuck then stop() return end + local ent = pace.current_part:GetOwner() + if not ent.GetHull then stop() return end + if not ent.GetHullDuck then stop() return end - local min, max = ent:GetHull() + local min, max = ent:GetHull() - if self.udata and self.udata.crouch then - min, max = ent:GetHullDuck() - RunConsoleCommand("+duck") - end + local part = pace.current_part + local height = part.StepSize + min = Vector(-part.HullWidth/2,-part.HullWidth/2,0) + max = Vector(part.HullWidth/2,part.HullWidth/2,0) + render.DrawWireframeBox( ent:GetPos() + Vector(0,0,height), Angle(0), min, max, hull_color, true ) - min = min * ent:GetModelScale() - max = max * ent:GetModelScale() + if time < os.clock() then + stop() + end + end) + elseif udata.view then + if not self.first_time then self.first_time = true return end + hook.Add("PostDrawOpaqueRenderables", "pace_draw_hull2", function() - render.DrawWireframeBox( ent:GetPos(), Angle(0), min, max, Color(255, 204, 51, 255), true ) + if not pace.current_part:IsValid() then stop() return end + if pace.current_part.ClassName ~= "entity2" then stop() return end - if time < os.clock() then - stop() + local ent = pace.current_part:GetOwner() + if not ent.GetHull then stop() return end + if not ent.GetHullDuck then stop() return end + + local min, max = ent:GetHull() + + if udata.crouch then + min, max = ent:GetHullDuck() + RunConsoleCommand("+duck") + end + + min = min * ent:GetModelScale() + max = max * ent:GetModelScale() + + local part = pace.current_part + local height = part.StandingViewOffset + if udata.crouch then + height = part.CrouchingViewOffset + end + render.DrawWireframeSphere( ent:GetPos() + Vector(0, 0, height), 8, 8, 8, hull_color, true ) + + if time < os.clock() then + stop() + end + end) + else + hook.Add("PostDrawOpaqueRenderables", "pace_draw_hull", function() + + if not pace.current_part:IsValid() then stop() return end + if pace.current_part.ClassName ~= "entity2" then stop() return end + + local ent = pace.current_part:GetOwner() + if not ent.GetHull then stop() return end + if not ent.GetHullDuck then stop() return end + + local min, max = ent:GetHull() + + if udata.crouch then + min, max = ent:GetHullDuck() + RunConsoleCommand("+duck") + end + + min = min * ent:GetModelScale() + max = max * ent:GetModelScale() + + render.DrawWireframeBox( ent:GetPos(), Angle(0), min, max, hull_color, true ) + + if time < os.clock() then + stop() + end + end) end - end) + end end pace.RegisterPanel(PANEL) diff --git a/lua/pac3/editor/client/panels/properties.lua b/lua/pac3/editor/client/panels/properties.lua index 1a4f561c2..2352374c1 100644 --- a/lua/pac3/editor/client/panels/properties.lua +++ b/lua/pac3/editor/client/panels/properties.lua @@ -86,54 +86,41 @@ local function install_generic_preview_hover(option, key, preview_value) end ---returns table +--all_paths is the pure sequential table ignoring gaps --start_index is the first known index --continuous is whether it's continuous (some series have holes) --end_index is the last known function pace.FindAssetSeriesBounds(base_directory, base_file, extension) - - --LEADING ZEROES FIX NOT YET IMPLEMENTED - local function leading_zeros(str) - str = string.StripExtension(str) - - local untilzero_pattern = "%f[1-9][0-9]+$" - local afterzero_pattern = "0+%f[1-9+]" - local beforenumbers_pattern = "%f[%f[1-9][0-9]+$]" - --string.gsub(str, "%f[1-9][0-9]+$", "") --get the start until the zeros stop - - --string.gsub(str, "0+%f[1-9+]", "") --leave start - - if string.find(str, afterzero_pattern) then - return string.gsub(str, untilzero_pattern, string.match(str, afterzero_pattern)) - end - end - --print(base_file .. "leading zeros?" , leading_zeros(base_file)) + if searched_cache_series_results[base_directory .. "/" .. base_file] then return searched_cache_series_results[base_directory .. "/" .. base_file] end local tbl = {} local i = 0 --try with 0 at first local keep_looking = true - local file_n local lookaheads_left = 15 local next_exists tbl.start_index = nil tbl.all_paths = {} - local index_compressed = 1 --increasing ID number of valid files while keep_looking do - - file_n = base_directory .. "/" .. base_file .. i .. "." .. extension - --print(file_n , "file" , file.Exists(file_n, "GAME") and "exists" or "doesn't exist") - --print("checking" , file_n) print("\tThe file" , file.Exists(file_n, "GAME") and "exists" or "doesn't exist") - if file.Exists(file_n, "GAME") then + if file.Exists(base_directory .. "/" .. base_file .. i .. "." .. extension, "GAME") then if not tbl.start_index then tbl.start_index = i end tbl.end_index = i - tbl.all_paths[index_compressed] = file_n - index_compressed = index_compressed + 1 + table.insert(tbl.all_paths, base_directory .. "/" .. base_file .. i .. "." .. extension) + elseif file.Exists(base_directory .. "/" .. base_file .. "0" .. i .. "." .. extension, "GAME") then + if not tbl.start_index then tbl.start_index = i end + tbl.end_index = i + table.insert(tbl.all_paths, base_directory .. "/" .. base_file .. "0" .. i .. "." .. extension) + elseif file.Exists(base_directory .. "/" .. base_file .. "00" .. i .. "." .. extension, "GAME") then + if not tbl.start_index then tbl.start_index = i end + tbl.end_index = i + table.insert(tbl.all_paths, base_directory .. "/" .. base_file .. "00" .. i .. "." .. extension) end - i = i + 1 - file_n = base_directory .. "/" .. base_file .. i .. "." .. extension - next_exists = file.Exists(file_n, "GAME") + next_exists = file.Exists(base_directory .. "/" .. base_file .. i .. "." .. extension, "GAME") + or file.Exists(base_directory .. "/" .. base_file .. "0" .. i .. "." .. extension, "GAME") + or file.Exists(base_directory .. "/" .. base_file .. "00" .. i .. "." .. extension, "GAME") + if not next_exists then if tbl.start_index then tbl.continuous = false end lookaheads_left = lookaheads_left - 1 @@ -2977,6 +2964,67 @@ do -- vector if input.IsKeyDown(KEY_ESCAPE) then pac.RemoveHook("DrawOverlay", "colorpicker") end end) end + + --light color tools + local btn3 = vgui.Create("DImageButton", self) + btn3:SetSize(line_height / 1.5, line_height / 1.5) + btn3:Dock(RIGHT) btn3:DockPadding(0,0,2 * line_height / 1.5,0) + btn3:SetTooltip("Light tools") + btn3:SetImage("icon16/lightbulb.png") + btn3.DoClick = function() + local menu = DermaMenu() + menu:SetPos(input.GetCursorPos()) + menu:MakePopup() + + local part = pace.current_part + local key = self.CurrentKey + local function apply_kelvin_color(str) + local color, temp = pac.TemperatureToColor(str) + if temp ~= nil then pace.color_temperature_active_temp = temp end + if color == nil then return end + local vec = color + if part.ProperColorRange then + vec = Vector(color.r/255,color.g/255,color.b/255) + else + vec = Vector(color.r,color.g,color.b) + end + if self:IsValid() then + self:SetValue(vec) + self.OnValueChanged(vec) + elseif part:IsValid() then + part:SetProperty(key, vec) + pace.PopulateProperties(part) + end + end + + menu:AddOption("From a Kelvin value or keyword", function() + Derma_StringRequest("Temperature to Color", "Provide a temperature in Kelvins between 1000-40000, or from the list of preselected keywords\ntype help to print the full list in the console", "1000", apply_kelvin_color) + end):SetImage("icon16/textfield.png") + menu:AddOption("Temperature explorer", function() + pac.OpenTemperatureToColorMenu(part, key, apply_kelvin_color) + end):SetImage("icon16/page_white_wrench.png") + menu:AddOption("Sample local lighting (raw)", function() + local color = render.GetLightColor(part:GetWorldPosition()) + local vec = color + if not part.ProperColorRange then + vec = 255*Vector(color.r,color.g,color.b) + end + self:SetValue(vec) + self.OnValueChanged(vec) + end):SetImage("icon16/lightbulb_off.png") + menu:AddOption("Sample local lighting (normalized)", function() + local color = render.GetLightColor(part:GetWorldPosition()) + local vec = color + local max_component = math.max(color.x, color.y, color.z) + --normalize the light + if max_component ~= 0 then color = color * (1/max_component) end + if not part.ProperColorRange then + vec = 255*Vector(color.r,color.g,color.b) + end + self:SetValue(vec) + self.OnValueChanged(vec) + end):SetImage("icon16/weather_sun.png") + end end end diff --git a/lua/pac3/editor/client/panels/tree.lua b/lua/pac3/editor/client/panels/tree.lua index 5191729d7..b19768987 100644 --- a/lua/pac3/editor/client/panels/tree.lua +++ b/lua/pac3/editor/client/panels/tree.lua @@ -170,7 +170,11 @@ do if part.ClassName == "custom_animation" then local anim = part:GetLuaAnimation() if anim then - node:SetText(part:GetName() .. " [" .. string.format("%.2f", anim.Frame + anim.FrameDelta) .. "]") + local ent = part:GetOwner() + local cycle = pac.animations.GetEntityAnimationCycle(ent, part:GetAnimID()) + local duration = pac.animations.GetAnimationDuration(ent, part:GetAnimID()) + local time = cycle ~= nil and duration and (cycle * duration) or 0 + node:SetText(part:GetName() .. " [" .. string.format("%.2fs", time) .. "]") end end @@ -179,6 +183,80 @@ do node:SetText(pace.pac_show_uniqueid:GetBool() and string.format("%s (%s)", part:GetName(), part:GetPrintUniqueID()) or part:GetName()) end + --live frame/time readout for named custom_animation_frame/custom_animation_time events (unnamed parts already get it via GetNiceName) + if part.ClassName == "event" and part.Name ~= "" then + local event_name = part:GetEvent() + if event_name == "custom_animation_frame" or event_name == "custom_animation_time" then + local base = pace.pac_show_uniqueid:GetBool() and string.format("%s (%s)", part:GetName(), part:GetPrintUniqueID()) or part:GetName() + local animation = part:GetProperty("animation") + local anim_part = animation ~= "" and pac.GetLocalPart(animation) or nil + local ent, anim_id + if IsValid(anim_part) and anim_part.ClassName == "custom_animation" then + ent = anim_part:GetOwner() + anim_id = anim_part:GetAnimID() + end + + local total + local duration + if anim_id then + local data = pac.animations.GetRegisteredAnimations()[anim_id] + if data then + total = #data.FrameData + local dur = 0 + for i = 1, #data.FrameData do + dur = dur + (1 / (data.FrameData[i].FrameRate or 1)) + end + duration = dur + end + end + + local window + if event_name == "custom_animation_frame" then + local start = part:GetProperty("frame_start") + if not start or start < 1 then start = 1 end + local endf = part:GetProperty("frame_end") + endf = endf and endf > 0 and endf or (total or "--") + window = start .. "-" .. tostring(endf) + else + local t_start = part:GetProperty("time_start") or 0 + if t_start < 0 then t_start = 0 end + local t_end = part:GetProperty("time_end") + if t_end and t_end > t_start then + -- explicit end time + elseif duration and duration > 0 then + t_end = duration + else + t_end = t_end or 0 + end + window = string.format("%.2f-%.2f%s", t_start, t_end, L("s")) + end + + local suffix + if IsValid(ent) and anim_id then + local frame, delta = pac.animations.GetEntityAnimationFrame(ent, anim_id) + if frame and delta then + if event_name == "custom_animation_frame" then + suffix = "[" .. L("frame") .. " " .. frame .. "/" .. (total or "--") .. " | " .. window .. "]" + else + local cycle = pac.animations.GetEntityAnimationCycle(ent, anim_id) + local dur = pac.animations.GetAnimationDuration(ent, anim_id) + local time = cycle ~= nil and dur and (cycle * dur) or 0 + suffix = "[" .. string.format("%.2f%s", time, L("s")) .. " | " .. window .. "]" + end + end + end + if not suffix then + if event_name == "custom_animation_frame" then + suffix = "[" .. L("frame") .. " --/" .. (total or "--") .. " | " .. window .. "]" + else + suffix = "[--.--" .. L("s") .. " | " .. window .. "]" + end + end + + node:SetText(base .. " " .. suffix) + end + end + if part:IsHiddenCached() then if not node.Icon.event_icon then local pnl = vgui.Create("DImage", node.Icon) diff --git a/lua/pac3/editor/client/parts.lua b/lua/pac3/editor/client/parts.lua index c60af2ea4..19cd5006a 100644 --- a/lua/pac3/editor/client/parts.lua +++ b/lua/pac3/editor/client/parts.lua @@ -4317,7 +4317,7 @@ function pace.addPartMenuComponent(menu, obj, option_name) MsgC(Color(200,200,200), " of total local parts)\n") end end) - elseif option_name == "arraying_menu" then + elseif option_name == "arraying_menu" and obj then local arraying_menu, pnl = menu:AddSubMenu(L"arraying menu", function() pace.OpenArrayingMenu(obj) end) pnl:SetImage("icon16/table_multiple.png") if obj.GetWorldPosition then local icon = obj.pace_tree_node.ModelPath or obj.Icon diff --git a/lua/pac3/editor/client/saved_parts.lua b/lua/pac3/editor/client/saved_parts.lua index cc54c1482..1c9064aff 100644 --- a/lua/pac3/editor/client/saved_parts.lua +++ b/lua/pac3/editor/client/saved_parts.lua @@ -23,7 +23,7 @@ file.CreateDir("pac3/__backup_save/") ]] local save_reduce = CreateClientConVar("pac_save_reduced", "1", true, false, "Whether to skip saving variables that stayed with the default values for pac3 parts. This will reduce file size.\nThe only possible problem is that if default values get changed (which shouldn't happen), it would change these values when loading.") -local save_compress = CreateClientConVar("pac_save_compressed", "0", true, false, "Whether to compress your pac3 outfits when saving. This will greatly reduce file size.\nThe only problem is that it'll render the outfit's txt unreadable for humans.\nWe do know people sometimes like to review outfit txt files, run searches or batch-replaces, keep that in mind.") +local save_compress = CreateClientConVar("pac_save_compressed", "0", true, false, "Whether to compress your pac3 outfits when saving. This will greatly reduce file size.\nThe only problem is that it'll render the outfit's txt unreadable for humans.\nWe do know people sometimes like to review outfit txt files, run searches or batch-replaces, keep that in mind.\nCompressed outfits can only be loaded by PAC3 versions from May 2026 or newer. When joining a server, PAC3 runs the server's version, so don't use this for outfits you wear on servers that might not be up to date.") local saveload_menu = NULL function pace.OutfitSaveMenu(title, subtitle, default, func, name, clear, override_part) @@ -52,6 +52,7 @@ function pace.OutfitSaveMenu(title, subtitle, default, func, name, clear, overri compress:SetConVar("pac_save_compressed") reduce:SetTextColor(frame:GetSkin().Colours.Label.Dark) compress:SetTextColor(frame:GetSkin().Colours.Label.Dark) + compress:SetTooltip("Compressed outfits can only be read by PAC3 versions from May 2026 or newer.\nWhen you're on a server, PAC3 runs the server's version, so an outdated server will fail to load compressed outfits and show them as empty.\nLeave this unchecked for outfits you share with others or wear on servers that might not be up to date.") local encoded = "" local function check_conflict() @@ -166,38 +167,64 @@ function pace.SaveParts(name, prompt_name, override_part, overrideAsUsual) data = pac.CallHook("pace.SaveParts", data) or data - if not override_part and #file.Find("pac3/sessions/*", "DATA") > 0 and not name:find("/") then - pace.luadata.WriteFile("pac3/sessions/" .. name .. ".txt", data) - else - if file.Exists("pac3/" .. name .. ".txt", "DATA") then - local date = os.date("%y-%m-%d-%H_%M_%S") - local read = file.Read("pac3/" .. name .. ".txt", "DATA") - file.Write("pac3/__backup_save/" .. name .. "_" .. date .. ".txt", read) + local is_session = not override_part and #file.Find("pac3/sessions/*", "DATA") > 0 and not name:find("/") + local path = is_session and "pac3/sessions/" .. name .. ".txt" or "pac3/" .. name .. ".txt" - local files, folders = file.Find("pac3/__backup_save/*", "DATA") + local function do_write() + if is_session then + pace.luadata.WriteFile(path, data) + else + if file.Exists(path, "DATA") then + local date = os.date("%y-%m-%d-%H_%M_%S") + local read = file.Read(path, "DATA") + file.Write("pac3/__backup_save/" .. name .. "_" .. date .. ".txt", read) - if #files > 30 then - local targetFiles = {} + local files, folders = file.Find("pac3/__backup_save/*", "DATA") - for i, filename in ipairs(files) do - local time = file.Time("pac3/__backup_save/" .. filename, "DATA") - table.insert(targetFiles, {"pac3/__backup_save/" .. filename, time}) - end + if #files > 30 then + local targetFiles = {} - table.sort(targetFiles, function(a, b) - return a[2] > b[2] - end) + for i, filename in ipairs(files) do + local time = file.Time("pac3/__backup_save/" .. filename, "DATA") + table.insert(targetFiles, {"pac3/__backup_save/" .. filename, time}) + end + + table.sort(targetFiles, function(a, b) + return a[2] > b[2] + end) - for i = 31, #files do - file.Delete(targetFiles[i][1]) + for i = 31, #files do + file.Delete(targetFiles[i][1]) + end end end + + pace.luadata.WriteFile(path, data) end - pace.luadata.WriteFile("pac3/" .. name .. ".txt", data) + pace.Backup(data, name) end - pace.Backup(data, name) + -- guard against destroying an outfit by saving an empty (or near empty) over it. + -- this happens when an outfit failed to load + if file.Exists(path, "DATA") and (file.Size(path, "DATA") or 0) >= 2048 then + local encoded = pace.luadata.Encode(data) + if #encoded < 1024 then + Derma_Query( + ("You are about to overwrite %s (%s) with a much smaller outfit (%s).\nThis usually means the outfit failed to load and you're about to save an empty outfit over the real outfit.\nThe current file will be backed up to pac3/__backup_save/.\n\nOverwrite anyway?"):format( + path, + string.NiceSize(file.Size(path, "DATA")), + string.NiceSize(#encoded) + ), + "PAC3 - confirm overwrite", + "overwrite anyway", do_write, + "cancel", function() pac.Message(("Save cancelled: %s"):format(path)) end + ) + return + end + end + + do_write() end local last_backup @@ -357,7 +384,14 @@ function pace.LoadParts(name, clear, override_part) return end - if str:StartsWith("LZMA COMPRESSED\n") then str = str:gsub("^LZMA COMPRESSED\n","") str = util.Decompress(str) end + if str:StartsWith("LZMA COMPRESSED\n") then + str = str:gsub("^LZMA COMPRESSED\n", "") + str = util.Decompress(str) + if not str then + pace.MessagePrompt("The outfit at the URL is LZMA compressed but could not be decompressed. The file may be corrupted or truncated.", "URL Failed", "OK") + return + end + end local data, err = pace.luadata.Decode(str) if not data then local message = string.format("Failed to load pac3 outfit from url: %s : %s\n", name, err) @@ -374,10 +408,42 @@ function pace.LoadParts(name, clear, override_part) else name = name:gsub("%.txt", "") - local data, err = pace.luadata.ReadFile("pac3/" .. name .. ".txt") + local path = "pac3/" .. name .. ".txt" + local data, err = pace.luadata.ReadFile(path) + + -- fall back to the sessions folder for autoload when the outfit is missing or empty + if name == "autoload" and (not data or (istable(data) and not next(data))) then + path = "pac3/sessions/" .. name .. ".txt" + data, err = pace.luadata.ReadFile(path, nil, true) + end + + if not data then + -- stay quiet when there simply is no autoload outfit + if err and name == "autoload" then + pace.MessagePrompt(err, "Autoload failed", "OK") + elseif name ~= "autoload" then + pace.MessagePrompt( + ("Failed to load %s:\n%s\n\nIf this outfit was saved with the LZMA compress option, it needs a PAC3 version from May 2026 or newer to load.\nWhen you're on a server, PAC3 runs the server's version, so the server may have to be updated."):format(name, err or "unknown error"), + ("Loading %s failed"):format(name), + "OK" + ) + end + return + end + + -- refuse to load it as an empty outfit, otherwise an empty outfits tends to get saved over the real outfit + if istable(data) and not next(data) and (file.Size(path, "DATA") or 0) >= 2048 then + pace.MessagePrompt( + ("%s decoded to an empty outfit, but the file is %s.\nThe file is likely corrupted, or was saved in a format this PAC3 version cannot fully read (e.g. LZMA compression from a newer version).\nThe outfit was NOT loaded, so you can't accidentally overwrite it."):format(name, string.NiceSize(file.Size(path, "DATA"))), + ("Loading %s failed"):format(name), + "OK" + ) + return + end + local has_possible_prop_pacs = false - if data and istable(data) then + if istable(data) then for i, part in pairs(data) do if part.self and isnumber(tonumber(part.self.OwnerName)) then has_possible_prop_pacs = true @@ -413,19 +479,6 @@ function pace.LoadParts(name, clear, override_part) end else - if name == "autoload" and (not data or not next(data)) then - data,err = pace.luadata.ReadFile("pac3/sessions/" .. name .. ".txt",nil,true) - if not data then - if err then - pace.MessagePrompt(err, "Autoload failed", "OK") - end - return - end - elseif not data then - pace.MessagePrompt(err, ("Decoding %s failed"):format(name), "OK") - return - end - pace.LoadPartsFromTable(data, clear, override_part) end end diff --git a/lua/pac3/editor/client/tools.lua b/lua/pac3/editor/client/tools.lua index 8acc042fc..886a55e87 100644 --- a/lua/pac3/editor/client/tools.lua +++ b/lua/pac3/editor/client/tools.lua @@ -930,3 +930,117 @@ pace.AddTool(L"clone and mirror this and children", function(part) end end) end) + +local function radial_sym(part, axis, num, i) + local rotD = (360/num)*i + + if part.SetPosition then + local pos, posO = part:GetPosition() + local x, y, z = pos:Unpack() + local xA, yA, zA = axis:Unpack() + + local rotR = math.rad(rotD) + local s, c = math.sin(rotR), math.cos(rotR) + + part:SetPosition(Vector( + (c+(xA^2)*(1-c))*x+(xA*yA*(1-c)-zA*s)*y+(xA*zA*(1-c)+yA*s)*z, + (yA*xA*(1-c)+zA*s)*x+(c+(yA^2)*(1-c))*y+(yA*zA*(1-c)-xA*s)*z, + (zA*xA*(1-c)-yA*s)*x+(zA*yA*(1-c)+xA*s)*y+(c+(zA^2)*(1-c))*z)) + else -- if the part is a group, we should still make its children radially symmetric + for _, part in ipairs(part:GetChildren()) do + radial_sym(part, axis, num, i) + end + end + + if part.SetAngles then + local ang = Angle(part:GetAngles()) -- cloning required, or else bad stuff happens + + ang:RotateAroundAxis(axis, rotD) + + part:SetAngles(ang) + end +end + +pace.AddTool(L"radial symmetry", function(part) + Derma_StringRequest(L"axis & number", L"input the axis to mirror around and the degree of symmetry.\ncan accept arbitrary vectors, e.g. 1 1 0 4", "z 4", function(input) + if input and part:IsValid() then + local args = string.Split(input, " ") + local axis, num + + if #args == 2 then + axis = axis_planes[string.lower(args[1])] + num = tonumber(args[2]) + elseif #args == 4 then + axis = Vector(args[1].." "..args[2].." "..args[3]) + if not axis:IsZero() then -- cannot work with zero vectors + axis:Normalize() + num = tonumber(args[4]) + end + end + + if axis and num and num >= 2 then -- try to avoid errors and weird situations + num = math.floor(num) + for i = 1, num-1 do + radial_sym(part:Clone(), axis, num, i) + end + end + end + end) +end) + +local function radial_sym_offset(part, axis, num, i) + local rotD = (360/num)*i + + if part.SetPositionOffset then + local pos, posO = part:GetPositionOffset() + local x, y, z = pos:Unpack() + local xA, yA, zA = axis:Unpack() + + local rotR = math.rad(rotD) + local s, c = math.sin(rotR), math.cos(rotR) + + part:SetPositionOffset(Vector( + (c+(xA^2)*(1-c))*x+(xA*yA*(1-c)-zA*s)*y+(xA*zA*(1-c)+yA*s)*z, + (yA*xA*(1-c)+zA*s)*x+(c+(yA^2)*(1-c))*y+(yA*zA*(1-c)-xA*s)*z, + (zA*xA*(1-c)-yA*s)*x+(zA*yA*(1-c)+xA*s)*y+(c+(zA^2)*(1-c))*z)) + else -- if the part is a group, we should still make its children radially symmetric + for _, part in ipairs(part:GetChildren()) do + radial_sym_offset(part, axis, num, i) + end + end + + if part.SetAngleOffset then + local ang = Angle(part:GetAngleOffset()) -- cloning required, or else bad stuff happens + + ang:RotateAroundAxis(axis, rotD) + + part:SetAngleOffset(ang) + end +end + +pace.AddTool(L"radial symmetry offset", function(part) + Derma_StringRequest(L"axis & number", L"input the axis to mirror around and the degree of symmetry for position and angle offsets.\ncan accept arbitrary vectors, e.g. 1 1 0 4", "z 4", function(input) + if input and part:IsValid() then + local args = string.Split(input, " ") + local axis, num + + if #args == 2 then + axis = axis_planes[string.lower(args[1])] + num = tonumber(args[2]) + elseif #args == 4 then + axis = Vector(args[1].." "..args[2].." "..args[3]) + if not axis:IsZero() then -- cannot work with zero vectors + axis:Normalize() + num = tonumber(args[4]) + end + end + + if axis and num and num >= 2 then -- try to avoid errors and weird situations + num = math.floor(num) + for i = 1, num-1 do + radial_sym_offset(part:Clone(), axis, num, i) + end + end + end + end) +end) \ No newline at end of file diff --git a/lua/pac3/editor/client/undo.lua b/lua/pac3/editor/client/undo.lua index 66b068165..5bbdcf8f6 100644 --- a/lua/pac3/editor/client/undo.lua +++ b/lua/pac3/editor/client/undo.lua @@ -91,6 +91,8 @@ pace.ClearUndo() local last_json function pace.RecordUndoHistory() + if pace.undoing then return end + local data = get_current_outfit() local json = util.TableToJSON(data) @@ -106,25 +108,35 @@ function pace.RecordUndoHistory() end function pace.Undo() - pace.UndoPosition = math.Clamp(pace.UndoPosition - 1, 0, #pace.UndoHistory) - local data = pace.UndoHistory[pace.UndoPosition] - - if data then - pace.ApplyDifference(data) - pace.FlashNotification("Undo position: " .. pace.UndoPosition .. "/" .. #pace.UndoHistory) - else - pace.FlashNotification('Nothing to undo') - end + pace.undoing = true + local ok, err = pcall(function() + pace.UndoPosition = math.Clamp(pace.UndoPosition - 1, 0, #pace.UndoHistory) + local data = pace.UndoHistory[pace.UndoPosition] + + if data then + pace.ApplyDifference(data) + pace.FlashNotification("Undo position: " .. pace.UndoPosition .. "/" .. #pace.UndoHistory) + else + pace.FlashNotification('Nothing to undo') + end + end) + pace.undoing = nil + if not ok then ErrorNoHalt(err) end end function pace.Redo() - pace.UndoPosition = math.Clamp(pace.UndoPosition + 1, 1, #pace.UndoHistory + 1) - local data = pace.UndoHistory[pace.UndoPosition] - - if data then - pace.ApplyDifference(data) - pace.FlashNotification("Undo position: " .. pace.UndoPosition .. "/" .. #pace.UndoHistory) - else - pace.FlashNotification('Nothing to redo') - end -end \ No newline at end of file + pace.undoing = true + local ok, err = pcall(function() + pace.UndoPosition = math.Clamp(pace.UndoPosition + 1, 1, #pace.UndoHistory + 1) + local data = pace.UndoHistory[pace.UndoPosition] + + if data then + pace.ApplyDifference(data) + pace.FlashNotification("Undo position: " .. pace.UndoPosition .. "/" .. #pace.UndoHistory) + else + pace.FlashNotification('Nothing to redo') + end + end) + pace.undoing = nil + if not ok then ErrorNoHalt(err) end +end diff --git a/lua/pac3/editor/client/util.lua b/lua/pac3/editor/client/util.lua index 4df3d63d2..3f466dfe3 100644 --- a/lua/pac3/editor/client/util.lua +++ b/lua/pac3/editor/client/util.lua @@ -176,7 +176,7 @@ function pace.MessagePrompt( strText, strTitle, strButtonText ) Window:MakePopup() Window:DoModal() - Window:PerformLayout() + Window:PerformLayout(Window:GetSize()) return Window end diff --git a/lua/pac3/editor/client/view.lua b/lua/pac3/editor/client/view.lua index 39d587831..09eb5bce3 100644 --- a/lua/pac3/editor/client/view.lua +++ b/lua/pac3/editor/client/view.lua @@ -739,8 +739,35 @@ function pace.PostRenderVGUI() surface.DrawText(notifText) end + if not pace.Focused and pace.Editor and pace.Editor:IsValid() then + local action = pace.UnfocusedVisible and "hide_editor_visible" or "hide_editor" + local binds = pace.PACActionShortcut and pace.PACActionShortcut[action] + + -- fall back to the hide_editor binding if the mode's action is unbound + if not (binds and binds[1]) then + binds = pace.PACActionShortcut and pace.PACActionShortcut["hide_editor"] + end + + local keys = binds and binds[1] and table.concat(binds[1], " + "):lower() or "ctrl + e" + + local text = pace.UnfocusedVisible + and L("input passthrough active - press '" .. keys .. "' to get the editor back") + or L("editor hidden - press '" .. keys .. "' to get the editor back") + + surface.SetFont('Trebuchet18') + surface.SetTextColor(255, 255, 255, 230) + local w = surface.GetTextSize(text) + surface.SetTextPos(ScrW() / 2 - w / 2, 10) + surface.DrawText(text) + end + if not isHoldingMovement then return end + if not pace.Focused then + isHoldingMovement = false + return + end + if pace.mctrl.LastThinkCall ~= FrameNumber() then surface.SetFont('Trebuchet18') surface.SetTextColor(color_white) diff --git a/lua/pac3/editor/server/wear.lua b/lua/pac3/editor/server/wear.lua index 6a35acfe7..42f525324 100644 --- a/lua/pac3/editor/server/wear.lua +++ b/lua/pac3/editor/server/wear.lua @@ -358,6 +358,14 @@ function pace.HandleReceivedData(ply, data) data.owner = ply data.uid = pac.Hash(ply) + -- security: clients must never dictate internal part state that gets + -- rebroadcast to everyone (PlayerOwner injection -> clientside RCE, V1/V2) + if istable(data.part) and istable(data.part.self) then + for _, key in ipairs({"PlayerOwner", "PlayerOwnerHash", "Owner", "ShowInEditor"}) do + data.part.self[key] = nil + end + end + if data.wear_filter and #data.wear_filter > game.MaxPlayers() then pac.Message("Player ", ply, " tried to submit extraordinary wear filter size of ", #data.wear_filter, ", dropping.") data.wear_filter = nil diff --git a/lua/pac3/libraries/animations.lua b/lua/pac3/libraries/animations.lua index 25dfc0858..618f17f17 100644 --- a/lua/pac3/libraries/animations.lua +++ b/lua/pac3/libraries/animations.lua @@ -232,6 +232,9 @@ function animations.RegisterAnimation(name, tInfo) tBoneTable.RU = tBoneTable.RU or 0 tBoneTable.RF = tBoneTable.RF or 0 tBoneTable.RR = tBoneTable.RR or 0 + tBoneTable.SX = tBoneTable.SX or 0 + tBoneTable.SY = tBoneTable.SY or 0 + tBoneTable.SZ = tBoneTable.SZ or 0 end end @@ -239,7 +242,7 @@ function animations.RegisterAnimation(name, tInfo) for iBoneUsed in pairs(BonesUsed) do for _, tFrame in ipairs(tInfo.FrameData) do if not tFrame.BoneInfo[iBoneUsed] then - tFrame.BoneInfo[iBoneUsed] = {MU = 0, MF = 0, MR = 0, RU = 0, RF = 0, RR = 0} + tFrame.BoneInfo[iBoneUsed] = {MU = 0, MF = 0, MR = 0, RU = 0, RF = 0, RR = 0, SX = 0, SY = 0, SZ = 0} end end end @@ -300,11 +303,20 @@ local function CubicInterpolation(y0, y1, y2, y3, mu) return a0 * mu * mu2 + (y0 - y1 - a0) * mu2 + (y2 - y0) * mu + y1 end -local EMPTYBONEINFO = {MU = 0, MR = 0, MF = 0, RU = 0, RR = 0, RF = 0} +local EMPTYBONEINFO = {MU = 0, MR = 0, MF = 0, RU = 0, RR = 0, RF = 0, SX = 0, SY = 0, SZ = 0} local function GetFrameBoneInfo(ent, tGestureTable, iFrame, iBoneID) local tPrev = tGestureTable.FrameData[iFrame] if tPrev then - return tPrev.BoneInfo[iBoneID] or tPrev.BoneInfo[ent:GetBoneName(iBoneID)] or EMPTYBONEINFO + local info = tPrev.BoneInfo[iBoneID] or tPrev.BoneInfo[ent:GetBoneName(iBoneID)] or EMPTYBONEINFO + + -- old animation data may not have the scale channels + if info.SX == nil then + info.SX = 0 + info.SY = info.SY or 0 + info.SZ = info.SZ or 0 + end + + return info end return EMPTYBONEINFO @@ -369,6 +381,11 @@ local function ProcessAnimations(ent) if not tBuffer[iBoneID] then tBuffer[iBoneID] = Matrix() end local mBoneMatrix = tBuffer[iBoneID] + -- old animation data may not have the scale channels + tBoneInfo.SX = tBoneInfo.SX or 0 + tBoneInfo.SY = tBoneInfo.SY or 0 + tBoneInfo.SZ = tBoneInfo.SZ or 0 + local vCurBonePos, aCurBoneAng = mBoneMatrix:GetTranslation(), mBoneMatrix:GetAngles() if not tBoneInfo.Callback or not tBoneInfo.Callback(ent, mBoneMatrix, iBoneID, vCurBonePos, aCurBoneAng, fFrameDelta, fPower) then local vUp = aCurBoneAng:Up() @@ -380,6 +397,7 @@ local function ProcessAnimations(ent) if tbl.Type == "posture" then mBoneMatrix:Translate((tBoneInfo.MU * vUp + tBoneInfo.MR * vRight + tBoneInfo.MF * vForward) * fAmount) mBoneMatrix:Rotate(Angle(tBoneInfo.RR, tBoneInfo.RU, tBoneInfo.RF) * fAmount) + mBoneMatrix:Scale(Vector(1 + tBoneInfo.SX * fAmount, 1 + tBoneInfo.SY * fAmount, 1 + tBoneInfo.SZ * fAmount)) else local bi1 = GetFrameBoneInfo(ent, tbl, iCurFrame - 1, iBoneID) @@ -400,6 +418,14 @@ local function ProcessAnimations(ent) Angle(tBoneInfo.RR, tBoneInfo.RU, tBoneInfo.RF) ) * fPower ) + + mBoneMatrix:Scale( + Vector(1, 1, 1) + LerpVector( + eases[curease](fFrameDelta), + Vector(bi1.SX, bi1.SY, bi1.SZ), + Vector(tBoneInfo.SX, tBoneInfo.SY, tBoneInfo.SZ) + ) * fPower + ) else mBoneMatrix:Translate( LerpVector( @@ -416,6 +442,14 @@ local function ProcessAnimations(ent) Angle(tBoneInfo.RR, tBoneInfo.RU, tBoneInfo.RF) ) * fPower ) + + mBoneMatrix:Scale( + Vector(1, 1, 1) + LerpVector( + fFrameDelta, + Vector(bi1.SX, bi1.SY, bi1.SZ), + Vector(tBoneInfo.SX, tBoneInfo.SY, tBoneInfo.SZ) + ) * fPower + ) end end elseif iInterp == "cubic" and tbl.FrameData[iCurFrame - 2] and tbl.FrameData[iCurFrame + 1] then @@ -431,13 +465,22 @@ local function ProcessAnimations(ent) Angle(bi3.RR, bi3.RU, bi3.RF), fFrameDelta ) * fPower) + mBoneMatrix:Scale(Vector(1, 1, 1) + CubicInterpolation( + Vector(bi0.SX, bi0.SY, bi0.SZ), + Vector(bi1.SX, bi1.SY, bi1.SZ), + Vector(tBoneInfo.SX, tBoneInfo.SY, tBoneInfo.SZ), + Vector(bi3.SX, bi3.SY, bi3.SZ), + fFrameDelta + ) * fPower) elseif iInterp == "none" then mBoneMatrix:Translate((tBoneInfo.MU * vUp + tBoneInfo.MR * vRight + tBoneInfo.MF * vForward)) mBoneMatrix:Rotate(Angle(tBoneInfo.RR, tBoneInfo.RU, tBoneInfo.RF)) + mBoneMatrix:Scale(Vector(1 + tBoneInfo.SX, 1 + tBoneInfo.SY, 1 + tBoneInfo.SZ)) else-- Default is Cosine local bi1 = GetFrameBoneInfo(ent, tbl, iCurFrame - 1, iBoneID) mBoneMatrix:Translate(CosineInterpolation(bi1.MU * vUp + bi1.MR * vRight + bi1.MF * vForward, tBoneInfo.MU * vUp + tBoneInfo.MR * vRight + tBoneInfo.MF * vForward, fFrameDelta) * fPower) mBoneMatrix:Rotate(CosineInterpolation(Angle(bi1.RR, bi1.RU, bi1.RF), Angle(tBoneInfo.RR, tBoneInfo.RU, tBoneInfo.RF), fFrameDelta) * fPower) + mBoneMatrix:Scale(Vector(1, 1, 1) + CosineInterpolation(Vector(bi1.SX, bi1.SY, bi1.SZ), Vector(tBoneInfo.SX, tBoneInfo.SY, tBoneInfo.SZ), fFrameDelta) * fPower) end end ::CONTINUE:: diff --git a/lua/pac3/libraries/luadata.lua b/lua/pac3/libraries/luadata.lua index e5164d2b8..fcd5afc07 100644 --- a/lua/pac3/libraries/luadata.lua +++ b/lua/pac3/libraries/luadata.lua @@ -346,7 +346,13 @@ do -- file extension return false,"invalid file" end --decode the header - if file:StartsWith("LZMA COMPRESSED\n") then file = file:gsub("^LZMA COMPRESSED\n","") file = util.Decompress(file) end + if file:StartsWith("LZMA COMPRESSED\n") then + file = file:gsub("^LZMA COMPRESSED\n", "") + file = util.Decompress(file) + if not file then + return false, "file is LZMA compressed but could not be decompressed (the file is corrupted)" + end + end return luadata.Decode(file) end end