增加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

@@ -107,6 +107,7 @@ public class consoleOnMono : MonoBehaviour
yield return GenerateLegacyTrackSubmodules(stats);
yield return GenerateLegacyPathNodes(elements, stats);
yield return GenerateLegacyTrackPoints(elements, stats);
yield return GenerateLegacyEnvironmentObjects(elements, stats);
yield return GenerateLegacyNotes(elements, stats);
yield return GenerateLegacyNoteVisualOverrides(elements, stats);
yield return GenerateLegacyAnimations(elements, stats);
@@ -201,26 +202,6 @@ public class consoleOnMono : MonoBehaviour
}, stats);
break;
case "EnvironmentObject_BM":
Debug.Log($"[LegacyImport] Processing EnvironmentObject_BM: {GetElementName(node, "unnamed")}");
TryCreateLegacyElement(node, () =>
{
GameElement parent = GetLegacyParentOrAncestor(node) ?? CreateFallbackFolder();
if (!TryFindLegacyEnvironmentObject(node, out string themeBundleName, out string objectName))
{
return null;
}
EnvironmentObject environmentObject = EnvironmentObject.GenerateElement(
GetElementName(node, "Legacy Environment Object"),
CreateGuid(), new List<string>(), true, themeBundleName, objectName, parent,
GetBool(node, "isStatic", false));
ApplyLegacySubstantialObject(environmentObject, node);
Debug.Log($"[LegacyImport] EnvironmentObject_BM created: oldObject='{GetString(node, "objectName", "?")}', newObject='{themeBundleName}/{objectName}', parent='{parent.elementName}'");
return environmentObject;
}, stats);
break;
case "TimeTrackPoint_BM":
Debug.Log($"[LegacyImport] Processing TimeTrackPoint_BM as CrossTrackPoint: {GetElementName(node, "unnamed")}");
TryCreateLegacyElement(node, () =>
@@ -432,6 +413,46 @@ public class consoleOnMono : MonoBehaviour
Debug.Log($"[LegacyImport] TrackPoints summary: TrackPercentPoint={trackPercentPointCount}");
}
private IEnumerator GenerateLegacyEnvironmentObjects(JSONNode elements, LegacyImportStats stats)
{
int environmentObjectCount = 0;
for (int i = 0; i < elements.Count; i++)
{
JSONNode node = elements[i];
if (GetLegacyTypeName(node) != "EnvironmentObject_BM")
{
continue;
}
environmentObjectCount++;
Debug.Log($"[LegacyImport] Processing EnvironmentObject_BM: {GetElementName(node, "unnamed")}");
TryCreateLegacyElement(node, () =>
{
GameElement parent = GetLegacyParentOrAncestor(node) ?? CreateFallbackFolder();
if (!TryFindLegacyEnvironmentObject(node, out string themeBundleName, out string objectName))
{
return null;
}
EnvironmentObject environmentObject = EnvironmentObject.GenerateElement(
GetElementName(node, "Legacy Environment Object"),
CreateGuid(), new List<string>(), true, themeBundleName, objectName, parent,
GetBool(node, "isStatic", false));
ApplyLegacySubstantialObject(environmentObject, node);
Debug.Log($"[LegacyImport] EnvironmentObject_BM created: oldObject='{GetString(node, "objectName", "?")}', newObject='{themeBundleName}/{objectName}', parent='{parent.elementName}'");
return environmentObject;
}, stats);
if (i % ElementsPerFrame == 0)
{
yield return null;
}
}
Debug.Log($"[LegacyImport] EnvironmentObject summary: EnvironmentObject={environmentObjectCount}");
}
private IEnumerator GenerateLegacyNotes(JSONNode elements, LegacyImportStats stats)
{
int[] noteCounts = new int[4]; // 0=Tap, 1=Stay, 2=Hold, 3=unknown skipped

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)

View File

@@ -1,4 +1,5 @@
using System.Collections;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Ichni.Editor.Commands;
@@ -110,10 +111,11 @@ namespace Ichni.Editor
}
}
public class CopyPasteDeleteModule
{
public GameElement copiedElement;
public List<GameElement> pastedElementList;
public class CopyPasteDeleteModule
{
public GameElement copiedElement;
public List<GameElement> pastedElementList;
private Dictionary<Guid, GameElement> pastedElementBySourceGuid;
public void CopyElement(GameElement gameElement)
{
@@ -150,7 +152,9 @@ namespace Ichni.Editor
LogWindow.Log("Pasted element: " + sourceElement.elementName + " to " + parentElement.elementName);
pastedElementList = new List<GameElement>();
pastedElementBySourceGuid = new Dictionary<Guid, GameElement>();
AffiliatedPaste(sourceElement, parentElement);
ResolvePastedReferences();
// 粘贴完成后,对所有新生成的元素执行 AfterInitialize 和 Refresh
// 与 EditorManager.LoadProject 的加载后流程对齐,确保 Manager 注册等关键步骤不遗漏。
@@ -164,6 +168,19 @@ namespace Ichni.Editor
return pastedElementList.FirstOrDefault();
}
private void ResolvePastedReferences()
{
if (pastedElementBySourceGuid == null || pastedElementBySourceGuid.Count == 0) return;
foreach (GameElement pastedElement in pastedElementList)
{
if (pastedElement is IPastedReferenceResolver referenceResolver)
{
referenceResolver.ResolvePastedReferences(pastedElementBySourceGuid);
}
}
}
public void DeleteElement(GameElement gameElement)
{
if (gameElement == null)
@@ -229,9 +246,10 @@ namespace Ichni.Editor
LogWindow.Log("Paste failed: " + gameElement.elementName + " could not be duplicated.", Color.red);
return;
}
pastedElementList.Add(pastedElement);
pastedElementList.Add(pastedElement);
pastedElementBySourceGuid[gameElement.elementGuid] = pastedElement;
gameElement.submoduleList.ForEach(submodule =>
{
if (submodule == null) return;
@@ -254,7 +272,12 @@ namespace Ichni.Editor
AffiliatedPaste(Element, pastedElement);
}
}
}
}
}
}
}
}
public interface IPastedReferenceResolver
{
void ResolvePastedReferences(IReadOnlyDictionary<Guid, GameElement> pastedElementBySourceGuid);
}
}