增加folder层面的QuickCopy

Signed-off-by: TRADER_FOER <lhf190@outlook.com>
This commit is contained in:
2026-07-25 00:17:04 +08:00
parent dd4d4e0e03
commit eb7110ea77
19 changed files with 548153 additions and 1676 deletions

View File

@@ -0,0 +1,199 @@
using System.Collections.Generic;
using Ichni.Editor;
using Ichni.RhythmGame.ThemeBundles.DepartureToMultiverse;
using UnityEngine;
namespace Ichni.RhythmGame
{
public partial class ElementFolder
{
private void QuickCopy(Vector3 unitPositionOffset, Vector3 unitRotationOffset, Vector3 unitScaleOffset, float unitTimeOffset, int iteration = 1)
{
if (iteration <= 0) return;
if (parentElement == null)
{
LogWindow.Log("Folder QuickCopy requires a parent element.", Color.red);
return;
}
CopyPasteDeleteModule cpd = EditorManager.instance.operationManager.CopyPasteDeleteModule;
cpd.CopyElement(this);
for (int i = 1; i <= iteration; i++)
{
ElementFolder newFolder = cpd.PasteElementRaw(this, parentElement) as ElementFolder;
if (newFolder == null) continue;
Vector3 positionOffset = unitPositionOffset * i;
Vector3 rotationOffset = unitRotationOffset * i;
Vector3 scaleOffset = unitScaleOffset * i;
float timeOffset = unitTimeOffset * i;
newFolder.ApplyFolderPositionOffset(positionOffset);
newFolder.ApplyFolderRotationOffset(rotationOffset);
newFolder.ApplyFolderScaleOffset(scaleOffset);
newFolder.ApplyElementFolderTimeOffsetRecursive(timeOffset, true);
newFolder.Refresh();
}
cpd.pastedElementList?.Clear();
}
private void ApplyFolderPositionOffset(Vector3 positionOffset)
{
if (transformSubmodule == null) return;
transformSubmodule.originalPosition += positionOffset;
transformSubmodule.Refresh();
}
private void ApplyFolderRotationOffset(Vector3 rotationOffset)
{
if (transformSubmodule == null) return;
transformSubmodule.originalEulerAngles += rotationOffset;
transformSubmodule.Refresh();
}
private void ApplyFolderScaleOffset(Vector3 scaleOffset)
{
if (transformSubmodule == null) return;
transformSubmodule.originalScale += scaleOffset;
transformSubmodule.Refresh();
}
private void ApplyElementFolderTimeOffsetRecursive(float offset, bool affectNotes)
{
List<GameElement> elements = GetAllGameElementsFromThis();
for (int i = 0; i < elements.Count; i++)
{
ApplyQuickCopyTimeOffsetToElement(elements[i], offset, affectNotes);
}
}
private static void ApplyQuickCopyTimeOffsetToElement(GameElement element, float offset, bool affectNotes)
{
if (element == null) return;
try
{
switch (element)
{
case AnimationBase animation:
animation.ApplyTimeOffset(offset);
animation.Refresh();
return;
case NoteBase note:
if (!affectNotes) return;
note.exactJudgeTime += offset;
if (note is Hold hold)
{
hold.holdEndTime += offset;
}
ApplyDurationOffset(note, offset, true);
note.UpdateNoteInTrack(EditorManager.instance.songInformation.songTime);
note.AddinNoteManager(false);
note.Refresh();
return;
case Track track:
ApplyDurationOffset(track, offset, true);
if (track.trackTimeSubmodule is TrackTimeSubmoduleMovable movable)
{
movable.trackStartTime += offset;
movable.trackEndTime += offset;
movable.Refresh();
}
track.Refresh();
return;
case TrackPercentPoint trackPercentPoint:
trackPercentPoint.trackPercent?.ApplyTimeOffset(offset);
ApplyDurationOffset(trackPercentPoint, offset, true);
trackPercentPoint.Refresh();
return;
case CrossTrackPoint crossTrackPoint:
ApplyFlexibleIntTimeOffset(crossTrackPoint.trackSwitch, offset);
crossTrackPoint.trackPercent?.ApplyTimeOffset(offset);
ApplyDurationOffset(crossTrackPoint, offset, true);
crossTrackPoint.Refresh();
return;
case TimeEffectsCollection timeEffectsCollection:
timeEffectsCollection.time += offset;
timeEffectsCollection.Refresh();
return;
case ParticleEmitter particleEmitter:
particleEmitter.playTime += offset;
particleEmitter.stopTime += offset;
ApplyDurationOffset(particleEmitter, offset, true);
particleEmitter.Refresh();
return;
case ParticleTracker particleTracker:
particleTracker.playTime += offset;
particleTracker.stopTime += offset;
particleTracker.Refresh();
return;
case ObjectTracker objectTracker:
objectTracker.playTime += offset;
objectTracker.stopTime += offset;
objectTracker.Refresh();
return;
case SkyboxSubsetter skyboxSubsetter:
if (skyboxSubsetter.blendTimeList != null)
{
for (int i = 0; i < skyboxSubsetter.blendTimeList.Count; i++)
{
skyboxSubsetter.blendTimeList[i] += offset;
}
}
skyboxSubsetter.Refresh();
return;
case DTMTrail dtmTrail:
dtmTrail.ApplyTimeOffset(offset);
dtmTrail.Refresh();
return;
case IHaveTimeDurationSubmodule hasDuration:
ApplyDurationOffset(hasDuration, offset, true);
element.Refresh();
return;
}
}
catch (System.Exception ex)
{
Debug.LogError($"[ElementFolderQuickCopy] Time offset failed on '{element.elementName}' ({element.GetType().Name}): {ex.Message}\n{ex.StackTrace}");
}
}
private static void ApplyDurationOffset(IHaveTimeDurationSubmodule element, float offset, bool onlyWhenOverriding)
{
TimeDurationSubmodule duration = element.timeDurationSubmodule;
if (duration == null) return;
if (onlyWhenOverriding && !duration.isOverridingDuration) return;
duration.startTime += offset;
duration.endTime += offset;
}
private static void ApplyFlexibleIntTimeOffset(FlexibleInt flexibleInt, float offset)
{
if (flexibleInt?.animations == null) return;
for (int i = 0; i < flexibleInt.animations.Count; i++)
{
flexibleInt.animations[i].ApplyTimeOffset(offset);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5ce75de045ac4fdaa86370a04faa919f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -53,6 +53,42 @@ namespace Ichni.RhythmGame
})
.Section("Tools", sectionOrder: 40)
.Button("QuickCopy", () =>
{
var inspectorMain = EditorManager.instance.uiManager.inspector;
IHaveInspection qcWindow = inspectorMain.GenerateSecondaryWindow(this, elementName + "'s Quick Copy");
var qcContainer = qcWindow.GenerateContainer();
var positionSub = qcContainer.GenerateSubcontainer(3);
var xField = qcWindow.GenerateInputField(positionSub, "X offset", "0");
var yField = qcWindow.GenerateInputField(positionSub, "Y offset", "0");
var zField = qcWindow.GenerateInputField(positionSub, "Z offset", "0");
var rotationSub = qcContainer.GenerateSubcontainer(3);
var rxField = qcWindow.GenerateInputField(rotationSub, "RX offset", "0");
var ryField = qcWindow.GenerateInputField(rotationSub, "RY offset", "0");
var rzField = qcWindow.GenerateInputField(rotationSub, "RZ offset", "0");
var scaleSub = qcContainer.GenerateSubcontainer(3);
var sxField = qcWindow.GenerateInputField(scaleSub, "SX offset", "0");
var syField = qcWindow.GenerateInputField(scaleSub, "SY offset", "0");
var szField = qcWindow.GenerateInputField(scaleSub, "SZ offset", "0");
var timeSub = qcContainer.GenerateSubcontainer(3);
var timeField = qcWindow.GenerateInputField(timeSub, "Time offset", "0");
var iterationField = qcWindow.GenerateInputField(timeSub, "Iteration", "1");
qcWindow.GenerateButton(this, timeSub, "Copy", () =>
{
Vector3 positionOffset = new Vector3(xField.GetValue<float>(), yField.GetValue<float>(), zField.GetValue<float>());
Vector3 rotationOffset = new Vector3(rxField.GetValue<float>(), ryField.GetValue<float>(), rzField.GetValue<float>());
Vector3 scaleOffset = new Vector3(sxField.GetValue<float>(), syField.GetValue<float>(), szField.GetValue<float>());
float timeOffset = timeField.GetValue<float>();
int iteration = iterationField.GetValue<int>();
QuickCopy(positionOffset, rotationOffset, scaleOffset, timeOffset, iteration);
});
})
.Span(3)
.Button("Time Shift Tool", () =>
{
GeneralSecondaryWindow timeShiftWindow = UnityEngine.Object.Instantiate(
@@ -114,81 +150,9 @@ namespace Ichni.RhythmGame
public void ApplyTimeShiftRecursive(bool affectNotes, float offset)
{
ApplyTimeShiftToElement(this, affectNotes, offset);
ApplyElementFolderTimeOffsetRecursive(offset, affectNotes);
}
/// <summary>
/// 递归偏移元素及其所有子元素的时间参数。
/// 处理顺序:先偏移当前元素,再递归处理子元素。
/// </summary>
private void ApplyTimeShiftToElement(GameElement element, bool affectNotes, float offset)
{
try
{
if (element == null) return;
switch (element)
{
case AnimationBase anim:
anim.ApplyTimeOffset(offset);
anim.Refresh();
break;
case NoteBase note when affectNotes:
note.exactJudgeTime += offset;
if (note is Hold hold)
{
hold.holdEndTime += offset;
}
note.UpdateNoteInTrack(EditorManager.instance.songInformation.songTime);
note.AddinNoteManager(false);
note.Refresh();
break;
case Track track:
// 偏移 Track 自身的 timeDurationSubmodule
if (track.timeDurationSubmodule != null && track.timeDurationSubmodule.isOverridingDuration)
{
track.timeDurationSubmodule.startTime += offset;
track.timeDurationSubmodule.endTime += offset;
}
// 偏移 TrackTimeSubmodule 中的轨道起止时间
if (track.trackTimeSubmodule is TrackTimeSubmoduleMovable movable)
{
movable.trackStartTime += offset;
movable.trackEndTime += offset;
movable.Refresh();
}
track.Refresh();
break;
case DTMTrail dtmTrail:
dtmTrail.ApplyTimeOffset(offset);
dtmTrail.Refresh();
break;
// 泛型兜底:处理 TemporaryObject、ParticleEmitter、GameCamera、TimeEffectsCollection 等
// 所有实现了 IHaveTimeDurationSubmodule 但不属于上述类型的元素
case IHaveTimeDurationSubmodule hasDuration:
hasDuration.ApplyTimeOffset(offset);
break;
}
}
catch (System.Exception ex)
{
Debug.LogError($"[TimeShift] 处理元素 '{element?.elementName}' ({element?.GetType().Name}) 时出错: {ex.Message}\n{ex.StackTrace}");
}
// 使用快照副本进行安全迭代,防止子操作意外修改集合
if (element.childElementList != null && element.childElementList.Count > 0)
{
List<GameElement> snapshot = new List<GameElement>(element.childElementList);
for (int i = 0; i < snapshot.Count; i++)
{
ApplyTimeShiftToElement(snapshot[i], affectNotes, offset);
}
}
}
#endregion
}
}

View File

@@ -9,7 +9,7 @@ namespace Ichni.RhythmGame
/// <summary>
/// 将物体的z轴指向目标物体注意LookAt的启用期间物体的旋转将被锁定
/// </summary>
public partial class LookAt : AnimationBase
public partial class LookAt : AnimationBase, IPastedReferenceResolver
{
#region [] Property Caches
public GameCamera gameCamera;
@@ -98,6 +98,22 @@ namespace Ichni.RhythmGame
enabling.animations.ForEach(anim => anim.ApplyTimeOffset(offset));
}
public void ResolvePastedReferences(IReadOnlyDictionary<Guid, GameElement> pastedElementBySourceGuid)
{
if (lookAtObject == null) return;
if (pastedElementBySourceGuid.TryGetValue(lookAtObject.elementGuid, out GameElement pastedTarget))
{
lookAtObject = pastedTarget;
targetTransformSubmodule = (animatedObject as IHaveTransformSubmodule)?.transformSubmodule;
if (targetTransformSubmodule != null)
{
targetTransformSubmodule.lookAt = this;
targetTransformSubmodule.eulerAnglesDirtyMark = true;
}
}
}
public void OnDestroy()
{
targetTransformSubmodule.eulerAnglesDirtyMark = true;
@@ -112,4 +128,4 @@ namespace Ichni.RhythmGame
}
#endregion
}
}
}

View File

@@ -10,7 +10,7 @@ namespace Ichni.RhythmGame
/// Synchronizes this element's world position to another transform-bearing element.
/// The target world position is converted back into this element parent's local space.
/// </summary>
public partial class PositionSync : AnimationBase
public partial class PositionSync : AnimationBase, IPastedReferenceResolver
{
#region [] Property Caches
public TransformSubmodule targetTransformSubmodule;
@@ -114,6 +114,21 @@ namespace Ichni.RhythmGame
enabling?.animations.ForEach(anim => anim.ApplyTimeOffset(offset));
}
public void ResolvePastedReferences(IReadOnlyDictionary<Guid, GameElement> pastedElementBySourceGuid)
{
if (syncTargetObject == null) return;
if (pastedElementBySourceGuid.TryGetValue(syncTargetObject.elementGuid, out GameElement pastedTarget))
{
syncTargetObject = pastedTarget;
targetTransformSubmodule = (animatedObject as IHaveTransformSubmodule)?.transformSubmodule;
if (targetTransformSubmodule != null)
{
targetTransformSubmodule.positionDirtyMark = true;
}
}
}
public void OnDestroy()
{
if (targetTransformSubmodule != null)