Project_WL/AgentScripts/WL778_Anim.cs

255 lines
13 KiB
C#
Raw Permalink Normal View History

WL 리뉴얼 세션 2026-09-06~07 (#760~#800) — 전투·배경·UI·타격감 (조직 PD 로그 #746~#800 · 대화로그 §14~§24) - 전투: Knight@Attack1~3_S 3콤보(4 컨트롤러·클래스별 서브클립) · 타겟팅 4규칙(정면 우선·재타겟·즉시 공격·어그로 금지 · WLTargetingSettings) · 대쉬 후 공격(Stander@Chase_Start · DashDriver · 5 m · 사정거리+0.8 · 근거리 적 우선) · 공격 이동 FrameTable(발 접지 실측 표 · AttackRootMotion) · 충돌 반경/투사체 0.3 배(WLCollisionTuning) · 무적(임시)·펫 금지(WLGameplaySettings) - 검기: NamuFX Slash_B 배리언트 Effect_WLSwingArc 원 피팅 정합 배치 + 캘리브레이션(SlashArcMeasure · SlashTrailSettings) · 찌르기 Effect_WLStab 대기 · 램프 리본(BladeTrail · WL_BladeRibbon.shader · T_WL_BladeRibbonRamp) 보존(drawRibbon 0) - 타격감: Assets/WL/Feel(WLHitFeel · 히트스톱 0.03 · 셰이크 0.10 m · 몹 펀치 1.12 · Actor.Get_Damage 훅 1줄 · 원본 RealCamera 셰이크 결함 대체) - 배경/맵: LMHPOLY Demo_01~10 → WL_Nature01~10(프리팹·씬·NavMesh·스포너·BattleMapConfig) · 물(ToonWaterU) · 포스트 블룸 0.9/0.3 · 잔디(BruteForce·드레싱) 제거 · 마젠타 머티리얼 URP 변환 - UI: 세로 HUD(WL_HUD · 하단 5메뉴 폭 전체 · 채팅/전투 패드 숨김 · WLIngameUiOverride) · Title/TitleInfo 1080×1920 Expand + 배경 높이 fit(WLBackgroundFit) · 로딩 SortOrder_5(WLRawImageAspectSync · 초점표 8장) · Loading1~8 ASTC 6×6 - 도구: AgentScripts/*(LightProbe · WL_MapSwitch · WL760~WL800 프로브/집행/검증 · 상단 사용법 주석) · 에디터 락 프로토콜 파일(staging) - 제외(별도 커밋 예정): Assets/LMHPOLY(703 MB) · Assets/Feel(422 MB) · Assets/Shinabro(300 MB) — 에셋 스토어/구 WL 팩 원본 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:24:25 +00:00
// WL778_Anim.cs — PD 지시 #778 콤보 전이 실측/집행 (에디트 모드 · UnityEditor.Animations API)
// unity command run_script --file AgentScripts/WL778_Anim.cs --entry WL778_Anim.Dump
// unity command run_script --file AgentScripts/WL778_Anim.cs --entry WL778_Anim.Apply --args '[0.45, 0.50, 0.85, 0.18]'
// unity command run_script --file AgentScripts/WL778_Anim.cs --entry WL778_Anim.Restore
//
// ── 왜 이름을 코드에 넣지 않는가 (C45) ────────────────────────────────────────
// 콤보 사슬의 상태 이름은 컨트롤러마다 다르다(실측):
// pcanim_onehand/bluntshield : attack → Attack2 → Attack3 → idle
// pcanim_spellblade : attack → Attack2 → Attack3 → MoveAttack1 → idle ← 4타!
// pcanim_paladin : attack → Sword&Shield_Attack2 → Sword&Shield_Attack3 → idle
// 그래서 이름 대신 **구조**로 사슬을 찾는다:
// 시작 = 클래스 테이블(table_classconfig)이 가리키는 컨트롤러의 Base Layer 에서
// "조건 없는 hasExitTime 전이"로만 이어지고, 목적지 클립에 Projectile 이벤트가 있는 상태.
// 사슬의 마지막 링크(목적지 클립에 Projectile 이벤트가 없음) = **회복 전이**.
// 컨트롤러 목록도 ClassConfig 테이블에서 읽는다(코드에 클래스 ID·경로를 넣지 않는다).
//
// 🔴 이름 충돌 주의 (2026-09-06 실측): 이 레포에는 전역 네임스페이스에 MonoBehaviour
// `AnimatorController` 가 두 개 있다 (Assets/ResWork/Suriyun/Scripts/AnimatorController.cs,
// Assets/ResWork/Suriyun/Cute Pet/Scripts/AnimatorController.cs). C# 이름 해석에서
// 전역 타입이 using 으로 들여온 타입을 이기므로 `using UnityEditor.Animations;` 를 써도
// `AnimatorController` 는 그 MonoBehaviour 로 잡힌다 (→ CS1061 layers 없음 ·
// LoadAssetAtPath<AnimatorController> 가 항상 null). 반드시 **완전 한정명**으로 쓴다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
public static class WL778_Anim
{
const string OutDir = "Screenshots_WL/combat4";
const string SnapPath = OutDir + "/anim_before.txt";
const string ClassTable = "Assets/ResWork/Table/Export/ClassConfig.json";
// ── 대상 컨트롤러 = ClassConfig 의 s_AnimationController 중 근접 전투 타입 ────
// 슬래시 프리팹을 가진 근접 클래스(OneHand·Shield)만 콤보 사슬을 갖는다(#747 실측).
static string[] TargetControllers()
{
var txt = System.IO.File.ReadAllText(ClassTable).TrimStart('');
var list = new List<string>();
foreach (System.Text.RegularExpressions.Match m in
System.Text.RegularExpressions.Regex.Matches(txt, "\\{[^{}]*\\}"))
{
string row = m.Value;
var bt = System.Text.RegularExpressions.Regex.Match(row, "\"e_BattleType\":\\s*\"([^\"]*)\"");
var ac = System.Text.RegularExpressions.Regex.Match(row, "\"s_AnimationController\":\\s*\"([^\"]*)\"");
if (!bt.Success || !ac.Success) continue;
if (bt.Groups[1].Value != "OneHand" && bt.Groups[1].Value != "Shield") continue;
if (!list.Contains(ac.Groups[1].Value)) list.Add(ac.Groups[1].Value);
}
return list.ToArray();
}
class Link
{
public AnimatorStateTransition tr;
public AnimatorState src, dst;
public bool dstIsAttack; // 목적지 클립에 Projectile 이벤트가 있는가 = 다음 타
}
static bool HasProjectile(AnimatorState s)
{
var clip = s != null ? s.motion as AnimationClip : null;
if (clip == null) return false;
var evs = AnimationUtility.GetAnimationEvents(clip);
for (int i = 0; i < evs.Length; i++)
if (evs[i].functionName == "Projectile") return true;
return false;
}
static float ProjectileNorm(AnimatorState s)
{
var clip = s != null ? s.motion as AnimationClip : null;
if (clip == null || clip.length <= 0f) return -1f;
var evs = AnimationUtility.GetAnimationEvents(clip);
for (int i = 0; i < evs.Length; i++)
if (evs[i].functionName == "Projectile") return evs[i].time / clip.length;
return -1f;
}
// 🔴 Unity 6000.3 실측: run_script 컴파일 환경에서 AnimatorController.layers 가 해석되지 않는다
// (CS1061 · 타입 자체는 UnityEditor.CoreModule 에 있다). 그래서 레이어 API 대신
// .controller 의 서브에셋으로 들어 있는 AnimatorStateMachine 에서 상태를 모은다.
// 상태 머신에 속하지 않은 고아 상태(pcanim_onehand 의 "Atk1 0" 등 7개)는 자연히 빠진다.
static AnimatorState[] AllStates(string path)
{
var set = new List<AnimatorState>();
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(path))
{
var sm = o as AnimatorStateMachine;
if (sm != null) Collect(sm, set);
}
return set.ToArray();
}
static void Collect(AnimatorStateMachine sm, List<AnimatorState> outList)
{
if (sm == null) return;
foreach (var cs in sm.states) if (cs.state != null && !outList.Contains(cs.state)) outList.Add(cs.state);
foreach (var child in sm.stateMachines) Collect(child.stateMachine, outList);
}
/// <summary>조건 없는 hasExitTime 전이 1개만 갖는 상태에서 사슬을 따라간다.</summary>
static List<Link> FindChain(UnityEditor.Animations.AnimatorController c, string path)
{
var states = AllStates(path);
// 시작 상태 = 클립에 Projectile 이 있고, 다른 어떤 공격 상태도 이 상태로 사슬을 잇지 않는 것.
var chainable = new Dictionary<AnimatorState, Link>();
foreach (var s in states)
{
if (!HasProjectile(s)) continue;
var cands = s.transitions.Where(t => t != null && !t.mute && t.hasExitTime
&& (t.conditions == null || t.conditions.Length == 0)
&& t.destinationState != null).ToArray();
if (cands.Length != 1) continue;
chainable[s] = new Link { tr = cands[0], src = s, dst = cands[0].destinationState,
dstIsAttack = HasProjectile(cands[0].destinationState) };
}
var incoming = new HashSet<AnimatorState>(chainable.Values.Where(l => l.dstIsAttack).Select(l => l.dst));
var heads = chainable.Keys.Where(s => !incoming.Contains(s)).ToArray();
var chain = new List<Link>();
if (heads.Length != 1) return chain; // 사슬이 하나로 특정되지 않으면 손대지 않는다
var cur = heads[0];
var guard = 0;
while (chainable.ContainsKey(cur) && guard++ < 16)
{
var link = chainable[cur];
chain.Add(link);
if (!link.dstIsAttack) break;
cur = link.dst;
}
return chain;
}
static string Row(UnityEditor.Animations.AnimatorController c, Link l, int idx)
{
var clip = l.src.motion as AnimationClip;
return string.Format(" [{0}] {1,-24} -> {2,-24} exit={3:F5} dur={4:F3} fixed={5} off={6:F3} intr={7} | clip={8} len={9:F3}s projNorm={10:F3} dstIsAttack={11}",
idx, l.src.name, l.dst.name, l.tr.exitTime, l.tr.duration, l.tr.hasFixedDuration,
l.tr.offset, l.tr.interruptionSource,
clip != null ? clip.name : "(null)", clip != null ? clip.length : 0f,
ProjectileNorm(l.src), l.dstIsAttack);
}
/// <summary>현재 전이 값을 읽어 보고하고, 되돌리기용 스냅샷을 남긴다.</summary>
public static object Dump()
{
System.IO.Directory.CreateDirectory(OutDir);
var sb = new StringBuilder();
var snap = new StringBuilder();
foreach (var path in TargetControllers())
{
var c = AssetDatabase.LoadAssetAtPath<UnityEditor.Animations.AnimatorController>(path);
if (c == null) { sb.AppendLine("MISSING " + path); continue; }
sb.AppendLine("## " + path);
var chain = FindChain(c, path);
if (chain.Count == 0) { sb.AppendLine(" (콤보 사슬을 특정하지 못함 — 손대지 않는다)"); continue; }
for (int i = 0; i < chain.Count; i++)
{
sb.AppendLine(Row(c, chain[i], i));
snap.AppendLine(string.Join("\t", new[] { path, chain[i].src.name, chain[i].dst.name,
chain[i].tr.exitTime.ToString("R"), chain[i].tr.duration.ToString("R"),
chain[i].tr.hasFixedDuration ? "1" : "0" }));
}
}
if (!System.IO.File.Exists(SnapPath) && snap.Length > 0)
{
System.IO.File.WriteAllText(SnapPath, snap.ToString());
sb.AppendLine("snapshot -> " + SnapPath);
}
else sb.AppendLine("snapshot 유지(이미 있음) -> " + SnapPath);
return sb.ToString();
}
/// <summary>
/// 콤보 전이를 당긴다.
/// link1 = 1타→2타 exitTime
/// linkN = 2타 이후 콤보 링크 exitTime (사슬이 4타면 3→4 에도 같은 값)
/// recov = 마지막 타 → 비공격 상태(회복) exitTime
/// dur = 콤보 링크 transitionDuration(초 · hasFixedDuration=true). 회복 전이는 건드리지 않는다.
/// 안전판: exitTime 은 그 상태의 Projectile 정규화 시각 + minMargin 아래로는 내려가지 않는다.
/// </summary>
public static object Apply(float link1, float linkN, float recov, float dur, float minMargin = 0.15f)
{
var sb = new StringBuilder();
foreach (var path in TargetControllers())
{
var c = AssetDatabase.LoadAssetAtPath<UnityEditor.Animations.AnimatorController>(path);
if (c == null) { sb.AppendLine("MISSING " + path); continue; }
var chain = FindChain(c, path);
if (chain.Count == 0) { sb.AppendLine("SKIP(사슬 미특정) " + path); continue; }
sb.AppendLine("## " + path);
for (int i = 0; i < chain.Count; i++)
{
var l = chain[i];
float before = l.tr.exitTime, beforeDur = l.tr.duration;
if (l.dstIsAttack)
{
float want = (i == 0) ? link1 : linkN;
float floorV = ProjectileNorm(l.src) + minMargin;
float applied = Mathf.Max(want, floorV);
l.tr.exitTime = applied;
l.tr.hasFixedDuration = true;
l.tr.duration = dur;
sb.AppendLine(string.Format(" COMBO {0} -> {1}: exit {2:F5} => {3:F5}{4} dur {5:F3} => {6:F3}",
l.src.name, l.dst.name, before, applied,
applied > want + 1e-5f ? " (Projectile+" + minMargin.ToString("F2") + " 하한 적용)" : "",
beforeDur, dur));
}
else
{
l.tr.exitTime = recov;
sb.AppendLine(string.Format(" RECOV {0} -> {1}: exit {2:F5} => {3:F5} dur {4:F3} (유지)",
l.src.name, l.dst.name, before, recov, beforeDur));
}
}
EditorUtility.SetDirty(c);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
/// <summary>Dump 가 남긴 스냅샷으로 되돌린다(C8 롤백 경로).</summary>
public static object Restore()
{
if (!System.IO.File.Exists(SnapPath)) return "snapshot 없음: " + SnapPath;
var sb = new StringBuilder();
foreach (var line in System.IO.File.ReadAllLines(SnapPath))
{
var p = line.Split('\t');
if (p.Length < 6) continue;
var c = AssetDatabase.LoadAssetAtPath<UnityEditor.Animations.AnimatorController>(p[0]);
if (c == null) continue;
foreach (var s in AllStates(p[0]))
{
if (s.name != p[1]) continue;
foreach (var t in s.transitions)
{
if (t.destinationState == null || t.destinationState.name != p[2]) continue;
t.exitTime = float.Parse(p[3]);
t.duration = float.Parse(p[4]);
t.hasFixedDuration = p[5] == "1";
sb.AppendLine("restored " + p[0] + " " + p[1] + "->" + p[2]);
}
}
EditorUtility.SetDirty(c);
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return sb.ToString();
}
}