Hygiene B4c: clean dedup helpers (knockback, bakers, editor rig)

- KnockbackUtil.Stamp: shared knockback-stamp used by AbilityFireSystem + MeleeComboSystem (guard + planar normalize + write; speed passed in).
- BakerStructureExt: AddPlacedStructure/AddDamageable extensions on Baker<T>; rewired Turret/Structure/Harvester/Conveyor/Fabricator bakers (baked data + serialized fields unchanged).
- EnemyPrefabBakeExt.AddEnemyPrefabPool: shared prefab-buffer bake for ZoneEnemyDirector/WaveDirector authoring (fields stay flat — no serialization change).
- Editor AnimRigUtil (HasParam + root-yaw overlay clip) shared by PlayerRigTools/EnemyRigTools; EnemyRigTools.MakeMat now GUID-preserving (CopySerialized over existing) — the composite AnimatorController copy left as-is with a caveat comment.

466/466 EditMode tests pass, 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 21:26:23 -07:00
parent e27a495530
commit 21a4c71413
19 changed files with 189 additions and 121 deletions
@@ -21,13 +21,7 @@ namespace ProjectM.Authoring
public override void Bake(ConveyorAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new PlacedStructure
{
Type = StructureType.Conveyor,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
this.AddPlacedStructure(entity, StructureType.Conveyor);
AddComponent(entity, new Conveyor
{
Direction = authoring.Direction,
@@ -30,13 +30,7 @@ namespace ProjectM.Authoring
public override void Bake(FabricatorAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new PlacedStructure
{
Type = StructureType.Fabricator,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
this.AddPlacedStructure(entity, StructureType.Fabricator);
AddComponent(entity, new Fabricator
{
InResourceId = authoring.InResourceId,
@@ -52,9 +46,7 @@ namespace ProjectM.Authoring
// machine, diverging from DR-032. Now that it IS targetable, the DamageEvent buffer must exist
// (an AI strike appends into it; absent = ECB-playback throw) + Destructible lets
// HealthApplyDamageSystem destroy it at 0 (occupancy auto-frees).
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
AddBuffer<DamageEvent>(entity);
AddComponent<Destructible>(entity);
this.AddDamageable(entity, authoring.MaxHp);
}
}
}
@@ -22,13 +22,7 @@ namespace ProjectM.Authoring
public override void Bake(HarvesterAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new PlacedStructure
{
Type = StructureType.Harvester,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
this.AddPlacedStructure(entity, StructureType.Harvester);
AddComponent(entity, new Harvester
{
ResourceId = authoring.OutputResourceId,
@@ -0,0 +1,39 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Baker helpers shared by the build-structure / automation-machine bakers. These emit the SAME component data
/// the bakers wrote inline before (no serialized authoring field or baked component change) — they only
/// deduplicate the repeated <see cref="PlacedStructure"/> stamp and the damageable triad
/// (<see cref="Health"/> + <see cref="DamageEvent"/> buffer + <see cref="Destructible"/>). Extension methods on
/// the concrete <see cref="Baker{TAuthoringType}"/> (not IBaker, whose AddComponent/AddBuffer are obsolete) so
/// the non-obsolete public Baker API resolves.
/// </summary>
public static class BakerStructureExt
{
/// <summary>Stamp PlacedStructure{Type=type} with the standard baked defaults (Cell/NextTick/LastProcessedTick set at placement).</summary>
public static void AddPlacedStructure<TAuthoring>(this Baker<TAuthoring> baker, Entity e, byte type)
where TAuthoring : Component
{
baker.AddComponent(e, new PlacedStructure
{
Type = type,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
}
/// <summary>Make an entity damageable/destructible: Health{Current=Max=maxHp} + the required DamageEvent buffer + Destructible tag.</summary>
public static void AddDamageable<TAuthoring>(this Baker<TAuthoring> baker, Entity e, float maxHp)
where TAuthoring : Component
{
baker.AddComponent(e, new Health { Current = maxHp, Max = maxHp });
baker.AddBuffer<DamageEvent>(e);
baker.AddComponent<Destructible>(e);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a54e2710fbe8cca4a9e84e9bbca4f7b9
@@ -24,19 +24,11 @@ namespace ProjectM.Authoring
public override void Bake(StructureAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new PlacedStructure
{
Type = authoring.Kind,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
this.AddPlacedStructure(entity, authoring.Kind);
// EB-1: Wall/Pylon are damageable + destructible AI targets (a wall soaks Husk strikes that would
// otherwise hit a turret). DamageEvent buffer MUST exist or an AI strike crashes at ECB playback.
// No HitRadius -> ProjectileDamageSystem ignores them (no friendly projectile fire).
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
AddBuffer<DamageEvent>(entity);
AddComponent<Destructible>(entity);
this.AddDamageable(entity, authoring.MaxHp);
}
}
}
@@ -21,13 +21,7 @@ namespace ProjectM.Authoring
public override void Bake(TurretAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new PlacedStructure
{
Type = StructureType.Turret,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
this.AddPlacedStructure(entity, StructureType.Turret);
AddComponent(entity, new Turret
{
Range = authoring.Range,
@@ -38,9 +32,7 @@ namespace ProjectM.Authoring
// destroys a Destructible at Health<=0). The DamageEvent buffer MUST exist on the archetype or an
// AI/turret strike crashes at ECB playback. NO HitRadius on purpose -> ProjectileDamageSystem (needs
// Health+HitRadius) ignores structures, so player shots never friendly-fire your own turret.
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
AddBuffer<DamageEvent>(entity);
AddComponent<Destructible>(entity);
this.AddDamageable(entity, authoring.MaxHp);
}
}
}
@@ -60,15 +60,7 @@ namespace ProjectM.Authoring
ClusterTightRadius = authoring.ClusterTightRadius,
});
var buffer = AddBuffer<WaveEnemyPrefab>(entity);
if (authoring.EnemyPrefabs != null)
{
foreach (var prefab in authoring.EnemyPrefabs)
{
if (prefab != null)
buffer.Add(new WaveEnemyPrefab { Prefab = GetEntity(prefab, TransformUsageFlags.Dynamic) });
}
}
this.AddEnemyPrefabPool<WaveDirectorAuthoring, WaveEnemyPrefab>(entity, authoring.EnemyPrefabs, e => new WaveEnemyPrefab { Prefab = e });
AddComponent(entity, new WaveState
{
@@ -58,15 +58,7 @@ namespace ProjectM.Authoring
ClusterTightRadius = authoring.ClusterTightRadius,
});
var buffer = AddBuffer<ZoneEnemyPrefab>(entity);
if (authoring.EnemyPrefabs != null)
{
foreach (var prefab in authoring.EnemyPrefabs)
{
if (prefab != null)
buffer.Add(new ZoneEnemyPrefab { Prefab = GetEntity(prefab, TransformUsageFlags.Dynamic) });
}
}
this.AddEnemyPrefabPool<ZoneEnemyDirectorAuthoring, ZoneEnemyPrefab>(entity, authoring.EnemyPrefabs, e => new ZoneEnemyPrefab { Prefab = e });
AddComponent(entity, new ZoneEnemyState
{
@@ -0,0 +1,31 @@
using System;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Shared BAKE step for the two enemy directors (Wave + Zone): add the prefab-pool buffer and fill it from the
/// authoring GameObject[] exactly as before — always add the (possibly empty) buffer, skip null entries, and
/// GetEntity(prefab, Dynamic) per entry. The element type differs per director (WaveEnemyPrefab vs
/// ZoneEnemyPrefab), so the caller supplies a tiny factory wrapping the resolved Entity in its element struct;
/// nothing about the serialized inspector fields or the baked buffer contents changes.
/// </summary>
public static class EnemyPrefabBakeExt
{
public static void AddEnemyPrefabPool<TAuthoring, TElem>(
this Baker<TAuthoring> baker, Entity e, GameObject[] prefabs, Func<Entity, TElem> make)
where TAuthoring : Component
where TElem : unmanaged, IBufferElementData
{
var buffer = baker.AddBuffer<TElem>(e);
if (prefabs == null)
return;
foreach (var prefab in prefabs)
{
if (prefab != null)
buffer.Add(make(baker.GetEntity(prefab, TransformUsageFlags.Dynamic)));
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b1404309f9af2e1409511d4743e3eb99
@@ -0,0 +1,45 @@
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
namespace ProjectM.EditorTools
{
/// <summary>
/// Shared editor-only helpers for the player/enemy Rukhanka rig tools (PlayerRigTools / EnemyRigTools):
/// AnimatorController parameter probing + the "overlay clip = full idle pose (every bone keyed, minus Root)
/// plus a Root-yaw twist" build. A Root-ONLY clip makes Rukhanka collapse every un-keyed bone to identity for
/// the state's duration -> the body sinks into the floor (writeDefaultValues does NOT prevent it), so both the
/// player swing and the enemy attack-windup base their clip on idle and drive only the Root yaw. The twist
/// keyframes differ per clip, so they are passed in.
/// </summary>
public static class AnimRigUtil
{
/// <summary>True if the controller already declares a parameter named <paramref name="name"/>.</summary>
public static bool HasParam(AnimatorController ac, string name)
{
foreach (var p in ac.parameters) if (p.name == name) return true;
return false;
}
/// <summary>
/// Rebuild <paramref name="clip"/> as the full <paramref name="idle"/> pose (all bindings except Root) with
/// the supplied Root localEulerAnglesRaw.y <paramref name="rootYaw"/> curve on top; sets loopTime=false and
/// saves. Height-preserving (avoids the un-keyed-bone collapse).
/// </summary>
public static void BuildRootYawOverlayClip(AnimationClip clip, AnimationClip idle, AnimationCurve rootYaw)
{
clip.ClearCurves();
foreach (var b in AnimationUtility.GetCurveBindings(idle))
{
if (b.path == "Root") continue; // the Root is driven below
AnimationUtility.SetEditorCurve(clip, b, AnimationUtility.GetEditorCurve(idle, b));
}
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), rootYaw);
var s = AnimationUtility.GetAnimationClipSettings(clip);
s.loopTime = false;
AnimationUtility.SetAnimationClipSettings(clip, s);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8824b7c4c8bbdd142858d1889c3ff308
+20 -22
View File
@@ -97,15 +97,26 @@ namespace ProjectM.EditorTools
static void MakeMat(string dst, string atlas, Color? tint)
{
if (AssetDatabase.LoadAssetAtPath<Material>(PlayerMat) == null)
var src = AssetDatabase.LoadAssetAtPath<Material>(PlayerMat);
if (src == null)
{ Debug.LogError($"[EnemyRigTools] Source material missing: {PlayerMat}"); return; }
AssetDatabase.DeleteAsset(dst);
if (!AssetDatabase.CopyAsset(PlayerMat, dst))
{ Debug.LogError($"[EnemyRigTools] CopyAsset failed -> {dst}"); return; }
AssetDatabase.ImportAsset(dst);
// GUID-preserving: overwrite an existing dst IN PLACE (CopySerialized keeps dst's GUID, so committed
// prefab material refs never break); only CopyAsset when dst is absent (first creation). The old
// DeleteAsset+CopyAsset minted a new GUID every run and silently orphaned those refs.
var m = AssetDatabase.LoadAssetAtPath<Material>(dst);
if (m == null)
{
if (!AssetDatabase.CopyAsset(PlayerMat, dst))
{ Debug.LogError($"[EnemyRigTools] CopyAsset failed -> {dst}"); return; }
AssetDatabase.ImportAsset(dst);
m = AssetDatabase.LoadAssetAtPath<Material>(dst);
}
else
{
EditorUtility.CopySerialized(src, m);
}
var tex = AssetDatabase.LoadAssetAtPath<Texture>(atlas);
if (tex == null) Debug.LogWarning($"[EnemyRigTools] Atlas not found: {atlas}");
if (m.HasProperty("_BaseColorMap") && tex != null) m.SetTexture("_BaseColorMap", tex);
@@ -128,26 +139,19 @@ namespace ProjectM.EditorTools
if (idle == null) { Debug.LogError("[EnemyRigTools] No Idle clip to base the attack on."); return; }
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(AttackClip);
if (clip == null) { clip = new AnimationClip { frameRate = 30f }; AssetDatabase.CreateAsset(clip, AttackClip); }
clip.ClearCurves();
foreach (var cb in AnimationUtility.GetCurveBindings(idle))
{ if (cb.path == "Root") continue; AnimationUtility.SetEditorCurve(clip, cb, AnimationUtility.GetEditorCurve(idle, cb)); }
var yaw = new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(0.15f, -38f), new Keyframe(0.40f, 0f));
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), yaw);
var s = AnimationUtility.GetAnimationClipSettings(clip);
s.loopTime = false;
AnimationUtility.SetAnimationClipSettings(clip, s);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
AnimRigUtil.BuildRootYawOverlayClip(clip, idle, yaw);
if (AssetDatabase.LoadAssetAtPath<AnimatorController>(PlayerController) == null)
{ Debug.LogError($"[EnemyRigTools] Source controller missing: {PlayerController}"); return; }
// GUID caveat: DeleteAsset+CopyAsset re-mints AC_EnemyTopDown's GUID each run; left as-is because CopySerialized onto a composite AnimatorController risks orphaning its sub-assets (states/transitions), unlike MakeMat's flat Material.
AssetDatabase.DeleteAsset(EnemyController);
AssetDatabase.CopyAsset(PlayerController, EnemyController);
AssetDatabase.ImportAsset(EnemyController);
var ac = AssetDatabase.LoadAssetAtPath<AnimatorController>(EnemyController);
if (!HasParam(ac, "IsAttacking"))
if (!AnimRigUtil.HasParam(ac, "IsAttacking"))
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
var sm = ac.layers[0].stateMachine;
@@ -172,12 +176,6 @@ namespace ProjectM.EditorTools
Debug.Log("[EnemyRigTools] AC_EnemyTopDown + EnemyAttackWindup clip built.");
}
static bool HasParam(AnimatorController ac, string name)
{
foreach (var p in ac.parameters) if (p.name == name) return true;
return false;
}
// ---- 3. Prefabs --------------------------------------------------------------------------------------
[MenuItem("ProjectM/Animation/Enemy Rigs - 3 Build Prefabs")]
@@ -30,22 +30,11 @@ namespace ProjectM.EditorTools
// Full idle pose (every bone keyed) + a Root yaw twist. See the class summary for why a Root-only clip sinks.
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(SwingClip);
if (clip == null) { clip = new AnimationClip { frameRate = 30f }; AssetDatabase.CreateAsset(clip, SwingClip); }
clip.ClearCurves();
foreach (var b in AnimationUtility.GetCurveBindings(idle))
{
if (b.path == "Root") continue; // the Root is driven below
AnimationUtility.SetEditorCurve(clip, b, AnimationUtility.GetEditorCurve(idle, b));
}
var yaw = new AnimationCurve(
new Keyframe(0f, 0f), new Keyframe(0.06f, -25f), new Keyframe(0.16f, 48f), new Keyframe(0.30f, 0f));
AnimationUtility.SetEditorCurve(clip, EditorCurveBinding.FloatCurve("Root", typeof(Transform), "localEulerAnglesRaw.y"), yaw);
var s = AnimationUtility.GetAnimationClipSettings(clip);
s.loopTime = false;
AnimationUtility.SetAnimationClipSettings(clip, s);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
AnimRigUtil.BuildRootYawOverlayClip(clip, idle, yaw);
if (!HasParam(ac, "IsAttacking"))
if (!AnimRigUtil.HasParam(ac, "IsAttacking"))
ac.AddParameter("IsAttacking", AnimatorControllerParameterType.Bool);
var sm = ac.layers[0].stateMachine;
@@ -80,12 +69,6 @@ namespace ProjectM.EditorTools
return null;
}
static bool HasParam(AnimatorController ac, string name)
{
foreach (var p in ac.parameters) if (p.name == name) return true;
return false;
}
static AnimatorState FindState(AnimatorStateMachine sm, string name)
{
foreach (var c in sm.states) if (c.state.name == name) return c.state;
@@ -143,17 +143,9 @@ namespace ProjectM.Simulation
});
// C3: the cone reads as weak vs the melee cleave without knockback — stamp it like melee
// (guarded: dummies lack KnockbackState → ECB throw; the boss is knockback-immune, A4).
if (m_KnockbackLookup.HasComponent(coneTargets[ci]) && !m_BossLookup.HasComponent(coneTargets[ci]))
{
float3 kd3 = coneTargetPos[ci] - xform.ValueRO.Position;
float2 kdir = math.lengthsq(kd3.xz) > 1e-6f ? math.normalize(kd3.xz) : cFace;
m_KnockbackLookup[coneTargets[ci]] = new KnockbackState
{
Dir = kdir,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
}
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)));
}
}
uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks);
@@ -0,0 +1,28 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Shared knockback stamp for melee/cone hits. Guarded exactly as the two call sites were: the target must own
/// <see cref="KnockbackState"/> (dummies/structures lacking it would throw at ECB playback if written) and must
/// NOT be a boss (<see cref="BossState"/> = knockback-immune, A4). The planar (XZ) heading is
/// normalize(targetPos - sourcePos), falling back to <paramref name="faceFallback"/> when that delta is
/// degenerate. Deduplicates the identical stamps in <see cref="AbilityFireSystem"/> (Warrior cone) and
/// <see cref="MeleeComboSystem"/> (melee cleave). Callers still gate their own speed/window (e.g. the melee
/// KnockSpeed &gt; 0 check) before calling.
/// </summary>
static class KnockbackUtil
{
public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> bossLookup,
Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick)
{
if (!lookup.HasComponent(target) || bossLookup.HasComponent(target))
return;
float3 delta = targetPos - sourcePos;
float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback;
lookup[target] = new KnockbackState { Dir = dir, Speed = speed, UntilTick = untilTick };
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fa42de08cd15e1841853823f81f94fa3
@@ -248,13 +248,9 @@ namespace ProjectM.Simulation
SourceNetworkId = c.OwnerId,
SourceTick = c.Stamp,
});
if (c.KnockSpeed > 0f && m_KnockbackLookup.HasComponent(target) && !m_BossLookup.HasComponent(target))
{
float3 d3 = enemyPositions[i] - c.From;
float2 kdir = new float2(d3.x, d3.z);
kdir = math.lengthsq(kdir) > 1e-6f ? math.normalize(kdir) : c.Face;
m_KnockbackLookup[target] = new KnockbackState { Dir = kdir, Speed = c.KnockSpeed, UntilTick = c.KnockUntil };
}
if (c.KnockSpeed > 0f)
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target,
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil);
}
}
// HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write