From 8ec0ee8eb2355e0068f29cb470475a376d458ffc Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 15 Sep 2026 14:59:32 +0900 Subject: [PATCH] =?UTF-8?q?[WL-816x]=20=ED=94=BC=EA=B2=A9=20=EC=9D=B4?= =?UTF-8?q?=ED=8E=99=ED=8A=B8=20=EC=83=81=EB=8B=A8=C2=B7150=20%=20=C2=B7?= =?UTF-8?q?=20=EB=AA=AC=EC=8A=A4=ED=84=B0=20150=20%=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=95=88=20=EC=A0=81=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=8C=80=EC=8B=9C=20=EC=96=B5=EC=A0=9C=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- Assets/WL/Combat/DashDriver.cs | 92 +++++++++++++++++++ Assets/WL/Combat/Reaction/EffectScaleGuard.cs | 72 +++++++++++++-- .../Combat/Reaction/WLEffectScaleSettings.cs | 25 +++++ .../Resources/WL/WLEffectScaleSettings.asset | 6 ++ Assets/WL/Combat/WLCombatMotionSettings.cs | 19 ++++ .../Resources/WL/WLCombatMotionSettings.asset | 5 + .../Resources/WL/WLGameplaySettings.asset | 7 ++ Assets/WL/Settings/WLCollisionTuning.cs | 49 +++++++++- Assets/WL/Settings/WLGameplaySettings.cs | 30 ++++++ 9 files changed, 295 insertions(+), 10 deletions(-) diff --git a/Assets/WL/Combat/DashDriver.cs b/Assets/WL/Combat/DashDriver.cs index e4ecd9ab1..ef39efb7d 100644 --- a/Assets/WL/Combat/DashDriver.cs +++ b/Assets/WL/Combat/DashDriver.cs @@ -94,6 +94,11 @@ namespace WL.Combat var cfg = WLCombatMotionSettings.Instance; if (cfg == null || !cfg.dashEnabled) return false; + // ── PD #816x 최종 게이트 — 사거리 안에 적이 있으면 대쉬 0, 그 적을 제자리에서 친다. + // 대쉬의 유일한 진입점이 여기(MyActor.cs:242)라 어떤 경로로 와도 반드시 통과한다. + if (cfg.dashBlockIfEnemyInReach && cfg.dashBlockFinalGate + && BlockByEnemyInReach(pc, target, reach, cfg)) return true; // true = 호출부가 Play_Run 을 부르지 않는다 + // 거리 창 밖이면 기존 동작. (min 미만은 애초에 여기까지 오지 않지만 방어적으로 본다) float minDist = Mathf.Max(cfg.dashMinDistance, reach + Mathf.Max(cfg.dashMinBeyondReach, 0f)); // PD #790: 사정거리 + 여유 밖에서만 대쉬 if (distance < minDist || distance > cfg.dashMaxDistance) return false; @@ -111,6 +116,93 @@ namespace WL.Combat return driver.BeginInternal(pc, agent, cfg, target, distance, reach); } + // ───────────────────────────────────────────────────────────────────────── + // PD #816x — 사거리 안 적 우선 · 대쉬 억제 + // + // PD 원문: "플레이어의 공격 사정거리에 적이 있을 경우에는 대시(몬스터에게 접근)하지 않고 + // 현재 위치에서 공격 사거리에 닿는 적을 공격하도록 해줘." + // + // 왜 여기인가 — 기존 #790 차단은 MyActor(원본) 안에서 `Find_Target_Facing(RetargetSearchRange, true)` + // 로만 돌았고, 그 '사거리'는 `Get_AttackReach`(정지거리 + 상대 반경 보정 + 크기 보정 + 여유)라 + // PD 가 화면에서 보는 **사거리 원**(WeaponTrailDriver.ResolveAttackReach = 기본 정지거리 + AttackRange_Up + 여유) + // 과 같은 값이 아니다. 두 해석이 갈리면 "원 안에 있는데 대쉬" 가 그대로 난다. + // 그래서 대쉬의 유일한 진입점에서 **두 해석의 큰 쪽 + 여유**로 다시 한 번 본다(값은 전부 SO). + // + // 🔴 원본 0줄 — 타겟 교체는 공개 API `Actor.Set_Target(Actor)` · 공격은 `PCActor.WL_PlayAttackNow()`. + // ───────────────────────────────────────────────────────────────────────── + + /// 게이트가 대쉬를 막은 누적 횟수 · 그중 타겟을 바꾼 횟수(검증용 읽기 전용). + public static int BlockedByReachCount, BlockedRetargetCount; + /// 마지막 차단 요약(검증용 문자열). + public static string LastBlockInfo = "(none)"; + + /// 게이트가 쓰는 사거리(m) — 전투 reach 와 화면 사거리 원 중 큰 쪽 + 여유. + public static float GateReach(float reach, WLCombatMotionSettings cfg) + { + float r = reach; + if (cfg.dashBlockUseRingReach) + { + float ring = WeaponTrailDriver.LastAttackReach; // 화면에 그려지는 그 값(816p 사거리 원과 동일 출처) + if (ring > r) r = ring; + } + return r + Mathf.Max(cfg.dashBlockReachBonus, 0f); + } + + /// 사거리 안에 적이 있으면 그 적을 제자리에서 치고 true(= 대쉬 안 함). + private static bool BlockByEnemyInReach(PCActor pc, Actor target, float reach, WLCombatMotionSettings cfg) + { + if (ActorInfo.Ins == null) return false; + + float gate = GateReach(reach, cfg); + if (gate <= 0.01f) return false; + + Vector3 my = pc.Get_position(); + var actors = ActorInfo.Ins.Get_EnemyActors(pc.IsEnemy(), my, gate); + Actor best = null; + float bestDist = float.MaxValue; + for (int i = 0; i < actors.Count; i++) + { + var a = actors[i]; + if (a == null || a.IsDead()) continue; + Vector3 d = a.Get_position() - my; d.y = 0f; + float dist = d.magnitude; + if (dist > gate) continue; // Get_EnemyActors 는 수직 성분도 보므로 수평으로 다시 자른다 + if (dist < bestDist) { bestDist = dist; best = a; } + } + ActorInfo.Ins.Release_Actors(actors); + + if (best == null) return false; + + BlockedByReachCount++; + bool retargeted = false; + if (cfg.dashBlockRetarget && best != target) + { + if (pc.IsMainPC()) + { + if (target != null) target.Set_Outline(false); + best.Set_Outline(true); + } + pc.Set_Target(best); // 공개 API — 원본 수정 0줄 + BlockedRetargetCount++; + retargeted = true; + } + + if (cfg.dashBlockLookAtTarget) + { + Vector3 dir = best.Get_position() - my; dir.y = 0f; + if (dir.sqrMagnitude > 0.0001f) pc.transform.rotation = Quaternion.LookRotation(dir); + } + + pc.WL_PlayAttackNow(); // 제자리 공격 (호출부는 CurAnim < Attack 구간이라 콤보를 끊지 않는다) + + LastBlockInfo = string.Format("gate={0:F2}m d={1:F2}m target={2} retarget={3}", + gate, bestDist, best.name, retargeted); +#if UNITY_EDITOR + if (cfg.verboseLog) Debug.Log("[WL #816x] 대쉬 억제 " + LastBlockInfo, pc); +#endif + return true; + } + private bool BeginInternal(PCActor pc, NavMeshAgent agent, WLCombatMotionSettings cfg, Actor target, float distance, float reach) { diff --git a/Assets/WL/Combat/Reaction/EffectScaleGuard.cs b/Assets/WL/Combat/Reaction/EffectScaleGuard.cs index cad0a2483..46dfbc9dd 100644 --- a/Assets/WL/Combat/Reaction/EffectScaleGuard.cs +++ b/Assets/WL/Combat/Reaction/EffectScaleGuard.cs @@ -59,6 +59,7 @@ namespace WL.Combat.Reaction static bool[] s_applied; static TurnOff_GO[] s_off; static bool[] s_offResolved; + static bool[] s_isHit; // #816x: 피격 이펙트인가(이름 접두사 판정 · 등록 때 1회) static int s_count; static int s_capacity; static readonly Dictionary s_map = new Dictionary(512); @@ -162,6 +163,10 @@ namespace WL.Combat.Reaction s_factor[slot] = r >= 0 ? Mathf.Max(0f, st.rules[r].scale) : 0f; if (r >= 0) RuledCount++; + // #816x — 피격 이펙트 판정(이름 접두사 · 등록 때 1회). 규칙이 없어도 위치·배율은 적용한다. + s_isHit[slot] = !string.IsNullOrEmpty(st.hitNamePrefix) + && nm.StartsWith(st.hitNamePrefix, StringComparison.Ordinal); + s_map[id] = slot; TrackedCount = s_count; return slot; @@ -172,15 +177,18 @@ namespace WL.Combat.Reaction { var t = s_tf[slot]; if (t == null) return; - if (s_rule[slot] < 0) return; // 규칙 없음 = 원본 100 % + bool hit = s_isHit[slot] && st.hitTopEnabled; // #816x + if (s_rule[slot] < 0 && !hit) return; // 규칙 없음 + 피격 이펙트도 아님 = 원본 100 % bool active = t.gameObject.activeSelf; if (active) { if (s_applied[slot]) return; - float f = ResolveFactor(slot, st); + float f = s_rule[slot] >= 0 ? ResolveFactor(slot, st) : 1f; + if (hit) f *= Mathf.Max(0.01f, st.hitScaleMultiplier); // #816x 크기 ×1.5 if (f <= 0f) return; t.localScale = s_orig[slot] * f; // 대입(멱등) — 누적 곱이 아니다 + if (hit) LiftToEnemyTop(t, st); // #816x 적 상단으로 이동 s_applied[slot] = true; AppliedCount++; ActiveScaled++; LastName = t.name; LastFactor = f; @@ -211,6 +219,55 @@ namespace WL.Combat.Reaction return Mathf.Clamp(byHeight, Mathf.Max(0.0001f, st.minScale), Mathf.Max(st.minScale, st.maxScale)); } + static void EnsureReflection() + { + if (s_reflectDone) return; + s_reflectDone = true; + s_fiTarget2 = typeof(TurnOff_GO).GetField("m_target2", BindingFlags.Instance | BindingFlags.NonPublic); + s_fiTfTop = typeof(Actor).GetField("tf_Top", BindingFlags.Instance | BindingFlags.NonPublic); + } + + // ───────────────────────────────────────── #816x 피격 이펙트 = 적 상단 + + /// 적 상단으로 올린 횟수 · 주인 적을 못 찾아 원본 위치로 둔 횟수 · 마지막 높이(검증용). + public static int HitTopApplied, HitTopMissed; + public static float LastHitTopY; + + /// + /// 피격 이펙트를 그 적의 **상단**(Actor.tf_Top = 게임이 eEffectLocation.Top 에 쓰는 머리 앵커)으로 올린다. + /// 원본은 몸 중심(`target.Get_Center_position()`)에 스폰하므로 y 만 갈아끼운다(x·z = 타격 지점 유지). + /// 주인 적을 못 찾으면 **아무것도 하지 않는다**(원본 위치 그대로 · HitTopMissed 로 센다). + /// + static void LiftToEnemyTop(Transform t, WLEffectScaleSettings st) + { + if (ActorInfo.Ins == null) { HitTopMissed++; return; } + EnsureReflection(); + if (s_fiTfTop == null) { HitTopMissed++; return; } + + Vector3 p = t.position; + float range = Mathf.Max(st.hitOwnerSearchRange, 0.1f); + var pc = MyValue.MyPC; + var actors = ActorInfo.Ins.Get_EnemyActors(pc != null && pc.IsEnemy(), p, range); + Actor best = null; float bestSq = float.MaxValue; + for (int i = 0; i < actors.Count; i++) + { + var a = actors[i]; + if (a == null) continue; + if (!st.hitApplyToBoss && a.IsSubRole(eSubRol.Boss)) continue; + float sq = (a.Get_position() - p).sqrMagnitude; + if (sq < bestSq) { bestSq = sq; best = a; } + } + ActorInfo.Ins.Release_Actors(actors); + if (best == null) { HitTopMissed++; return; } + + var top = s_fiTfTop.GetValue(best) as Transform; // 참조형 필드 → 박싱 없음 + if (top == null) { HitTopMissed++; return; } + + p.y = top.position.y + st.hitTopExtraY; + t.position = p; + HitTopApplied++; LastHitTopY = p.y; + } + /// 이 이펙트가 따라다니는 대상 액터의 실높이(발밑→머리). 원본 private 필드라 리플렉션(캐시 1회). static bool TryTargetHeight(int slot, out float height) { @@ -224,12 +281,7 @@ namespace WL.Combat.Reaction var off = s_off[slot]; if (off == null) return false; - if (!s_reflectDone) - { - s_reflectDone = true; - s_fiTarget2 = typeof(TurnOff_GO).GetField("m_target2", BindingFlags.Instance | BindingFlags.NonPublic); - s_fiTfTop = typeof(Actor).GetField("tf_Top", BindingFlags.Instance | BindingFlags.NonPublic); - } + EnsureReflection(); if (s_fiTarget2 == null || s_fiTfTop == null) return false; var actor = s_fiTarget2.GetValue(off) as Actor; // 참조형 필드 → 박싱 없음 @@ -286,6 +338,7 @@ namespace WL.Combat.Reaction Array.Resize(ref s_applied, want); Array.Resize(ref s_off, want); Array.Resize(ref s_offResolved, want); + Array.Resize(ref s_isHit, want); s_capacity = want; } @@ -300,7 +353,7 @@ namespace WL.Combat.Reaction { s_id[w] = s_id[i]; s_tf[w] = s_tf[i]; s_orig[w] = s_orig[i]; s_factor[w] = s_factor[i]; s_rule[w] = s_rule[i]; s_applied[w] = s_applied[i]; - s_off[w] = s_off[i]; s_offResolved[w] = s_offResolved[i]; + s_off[w] = s_off[i]; s_offResolved[w] = s_offResolved[i]; s_isHit[w] = s_isHit[i]; } w++; } @@ -330,6 +383,7 @@ namespace WL.Combat.Reaction SweepCount = TrackedCount = RuledCount = 0; AppliedCount = RestoredCount = SkippedCount = ActiveScaled = 0; HeightResolved = HeightMissed = 0; + HitTopApplied = HitTopMissed = 0; LastHitTopY = 0f; LastName = ""; LastFactor = 0f; } } diff --git a/Assets/WL/Combat/Reaction/WLEffectScaleSettings.cs b/Assets/WL/Combat/Reaction/WLEffectScaleSettings.cs index af5a51736..eed534908 100644 --- a/Assets/WL/Combat/Reaction/WLEffectScaleSettings.cs +++ b/Assets/WL/Combat/Reaction/WLEffectScaleSettings.cs @@ -151,6 +151,31 @@ namespace WL.Combat.Reaction [Tooltip("대상 높이 기반 계산의 배율 상한(보스처럼 큰 대상에서 원본보다 커지는 것 방지).")] public float maxScale = 1.5f; + // ── 피격 이펙트 위치·크기 (#816x) ──────────────────────────────────────── + // PD 지시 #816 (2026-09-15): "적 타격시 발생하는 피격 이펙트가 적 상단에 발생하게 하고, 크기를 50% 키워줘." + // 원본은 `ProjectileBase.Show_HitEffect → Get_HitEffectPos`(= target.Get_Center_position() 또는 투사체 위치) + // 로 **몸 중심**에 스폰한다. 원본 1줄도 못 고치므로 EffectScaleGuard 의 풀 스윕이 + // '활성화된 그 프레임에'(LateUpdate · 렌더 앞) 위치를 적 상단으로 올리고 배율을 곱한다. + // '적 상단' = 게임이 eEffectLocation.Top 에 쓰는 그 앵커(Actor.tf_Top) = 머리 높이. + [Header("피격 이펙트 위치·크기 (PD #816x)")] + [Tooltip("켜면 이름이 hitNamePrefix 로 시작하는 이펙트를 적 상단(Actor.tf_Top)으로 올린다. 끄면 원본 위치(몸 중심) 그대로")] + public bool hitTopEnabled = true; + + [Tooltip("대상 이름 접두사. 원본 히트 이펙트는 전부 Effect_Hit_<클래스ID> · Effect_Hit_<보스스킬> 이다")] + public string hitNamePrefix = "Effect_Hit_"; + + [Tooltip("피격 이펙트 크기 배율(위 rules 로 정해진 배율에 추가로 곱한다). 1.5 = PD 지시 '50% 크게'")] + public float hitScaleMultiplier = 1.5f; + + [Tooltip("적 상단(머리 끝)에서 더 올릴 높이(m). 0 이면 정확히 머리 끝")] + public float hitTopExtraY = 0.1f; + + [Tooltip("이펙트가 어느 적의 것인지 찾는 반경(m). 원본이 그 적의 몸 중심에 스폰하므로 짧아도 된다")] + public float hitOwnerSearchRange = 2.5f; + + [Tooltip("켜면 보스에게도 적용한다. 끄면 보스 피격 이펙트는 원본 위치·크기 그대로")] + public bool hitApplyToBoss = true; + [Header("규칙 (배열 순서대로 검사 · 처음 맞는 것 · 안 맞으면 손대지 않는다 = 원본 100 %)")] public EffectScaleRule[] rules = new EffectScaleRule[0]; diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLEffectScaleSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLEffectScaleSettings.asset index 42726d62b..23786cc73 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLEffectScaleSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLEffectScaleSettings.asset @@ -26,6 +26,12 @@ MonoBehaviour: referenceHeight: 1.191 minScale: 0.05 maxScale: 1.5 + hitTopEnabled: 1 + hitNamePrefix: Effect_Hit_ + hitScaleMultiplier: 1.5 + hitTopExtraY: 0.1 + hitOwnerSearchRange: 2.5 + hitApplyToBoss: 1 rules: - prefab: Effect_Stun match: 0 diff --git a/Assets/WL/Combat/WLCombatMotionSettings.cs b/Assets/WL/Combat/WLCombatMotionSettings.cs index 1e6aed60e..09765b271 100644 --- a/Assets/WL/Combat/WLCombatMotionSettings.cs +++ b/Assets/WL/Combat/WLCombatMotionSettings.cs @@ -200,6 +200,25 @@ namespace WL.Combat public bool dashBlockIfEnemyInReach = true; public static bool DashBlockIfEnemyInReach { get { var i = Instance; return i != null && i.dashBlockIfEnemyInReach; } } + // ── PD #816x (2026-09-15) "사정거리에 적이 있으면 대쉬하지 말고 제자리에서 그 적을 공격" ── + // 기존 #790 차단은 MyActor 안(원본)에서만 돌아 놓치는 경우가 있었다(락 타겟·사거리 해석 차이). + // 아래 값들은 **대쉬의 유일한 진입점인 DashDriver.TryBegin** 에서 마지막으로 한 번 더 본다. + [Tooltip("대쉬 직전 최종 게이트를 켠다. 사거리 안에 적이 하나라도 있으면 대쉬를 시작하지 않는다")] + public bool dashBlockFinalGate = true; + + [Tooltip("게이트가 보는 사거리에 화면 사거리 원(WeaponTrailDriver.LastAttackReach)을 함께 쓴다. " + + "PD 화면에서 원 안에 보이는 적이 게이트에도 '안'으로 잡히게 하는 스위치")] + public bool dashBlockUseRingReach = true; + + [Tooltip("게이트 사거리에 더하는 여유(m). 원 테두리에 걸친 적까지 '안'으로 본다")] + public float dashBlockReachBonus = 0.35f; + + [Tooltip("게이트가 사거리 안 적을 찾으면 타겟을 그 적(가장 가까운)으로 바꾼다. 끄면 기존 타겟을 그대로 두고 대쉬만 막는다")] + public bool dashBlockRetarget = true; + + [Tooltip("제자리 공격 직전에 그 적 쪽으로 몸을 돌린다(수평 회전만)")] + public bool dashBlockLookAtTarget = true; + [Header("■ 검증 (에디터 전용)")] [Tooltip("켜면 전진 시작·종료·이동량을 콘솔에 남긴다")] public bool verboseLog = false; diff --git a/Assets/WL/Settings/Resources/WL/WLCombatMotionSettings.asset b/Assets/WL/Settings/Resources/WL/WLCombatMotionSettings.asset index 4b00b3152..bb43f5946 100644 --- a/Assets/WL/Settings/Resources/WL/WLCombatMotionSettings.asset +++ b/Assets/WL/Settings/Resources/WL/WLCombatMotionSettings.asset @@ -29,6 +29,11 @@ MonoBehaviour: dashFailCooldownSeconds: 2 dashMinBeyondReach: 0.8 dashBlockIfEnemyInReach: 1 + dashBlockFinalGate: 1 + dashBlockUseRingReach: 1 + dashBlockReachBonus: 0.35 + dashBlockRetarget: 1 + dashBlockLookAtTarget: 1 dashStateName: dash dashClipName: Chase_Start dashCrossFadeSeconds: 0.06 diff --git a/Assets/WL/Settings/Resources/WL/WLGameplaySettings.asset b/Assets/WL/Settings/Resources/WL/WLGameplaySettings.asset index ab8498662..47258009d 100644 --- a/Assets/WL/Settings/Resources/WL/WLGameplaySettings.asset +++ b/Assets/WL/Settings/Resources/WL/WLGameplaySettings.asset @@ -29,6 +29,13 @@ MonoBehaviour: eliteProjectileScale: 0.3 bossProjectileScale: 0.3 projectileScaleVisibleOnly: 1 + mobBodyScaleEnabled: 1 + mobBodyScale: 1.5 + eliteBodyScale: 1 + bossBodyScale: 1 + mobBodyScaleKeepUnderBossThreshold: 0 + bodyScaleBossThreshold: 2 + bodyScaleThresholdEpsilon: 0.02 hideBattlePad: 0 battlePadPaths: - IngameUIs/BattleUI diff --git a/Assets/WL/Settings/WLCollisionTuning.cs b/Assets/WL/Settings/WLCollisionTuning.cs index b8dbee3b8..237d493e3 100644 --- a/Assets/WL/Settings/WLCollisionTuning.cs +++ b/Assets/WL/Settings/WLCollisionTuning.cs @@ -61,7 +61,12 @@ namespace WL.Settings public static void ApplyMob(NavMeshAgent agent, eSubRol subRole) { var s = WLGameplaySettings.Instance; - if (agent == null || s == null || !s.agentTuningEnabled) return; + if (agent == null || s == null) return; + + // #816x — 몸 크기(PD "기본적인 몬스터의 크기도 50% 키워줘"). 에이전트 튜닝 스위치와 독립이다. + ApplyMobBodyScale(agent.transform, subRole, s); + + if (!s.agentTuningEnabled) return; bool isBoss = subRole == eSubRol.Boss; bool isElite = subRole == eSubRol.Elite; @@ -81,6 +86,48 @@ namespace WL.Settings agent.obstacleAvoidanceType = (ObstacleAvoidanceType)(int)s.mobAvoidance; } + // ── #816x 몬스터 몸 크기 ──────────────────────────────────────────────── + /// 몸 크기를 적용한 누적 횟수 · 보스 경계에서 잘린 횟수 · 마지막 값(검증용 읽기 전용). + public static int BodyScaleApplied, BodyScaleClamped; + public static float LastBodyScaleBase, LastBodyScaleApplied; + + /// + /// 몬스터 루트 스케일에 타입별 배율을 곱한다(#816x · PD "기본적인 몬스터의 크기도 50% 키워줘"). + /// + /// 호출 시점 = MobActor.Set 안, `transform.localScale = f_DefaultScale * Vector3.one`(:87) **직후**(:118). + /// 따라서 base 는 언제나 테이블 기본값이고, 풀에서 재사용돼도 다시 기본값에서 곱한다 = **누적 없음**. + /// 루트 스케일이므로 자식(콜라이더·tf_Top·HP 바)은 함께 커지고, + /// NavMeshAgent.radius(월드 단위 · 바로 아래에서 별도 계산) 와 데이터 값(이동 속도 · f_BaseATKRange)은 **그대로다**. + /// + public static void ApplyMobBodyScale(Transform root, eSubRol subRole, WLGameplaySettings s) + { + if (root == null || s == null || !s.mobBodyScaleEnabled) return; + + float m = subRole == eSubRol.Boss ? s.bossBodyScale + : subRole == eSubRol.Elite ? s.eliteBodyScale + : s.mobBodyScale; + if (m <= 0f || Mathf.Abs(m - 1f) < 0.0001f) return; + + float bas = root.localScale.x; + if (bas <= 0.0001f) return; + + float want = bas * m; + + // 원본이 localScale.x > 2 를 '보스급' 판정에 쓴다(ProjectileBase:305/308 · Actor:950). + // 스위치가 켜져 있을 때만 그 경계를 넘지 않게 자른다(기본은 꺼짐 = PD 지시 그대로 ×배율). + float th = s.bodyScaleBossThreshold; + if (s.mobBodyScaleKeepUnderBossThreshold && th > 0f && bas <= th && want > th) + { + want = th - Mathf.Max(s.bodyScaleThresholdEpsilon, 0f); + BodyScaleClamped++; + } + + root.localScale = Vector3.one * want; + BodyScaleApplied++; + LastBodyScaleBase = bas; + LastBodyScaleApplied = want; + } + /// /// 이 투사체에 곱할 크기 배율. 1 이면 변경하지 않는다. /// 플레이어·펫 투사체는 항상 1(무변경) — 몬스터(eRole.Mob)가 쏜 것만 대상. diff --git a/Assets/WL/Settings/WLGameplaySettings.cs b/Assets/WL/Settings/WLGameplaySettings.cs index c686dcc5f..414c978d4 100644 --- a/Assets/WL/Settings/WLGameplaySettings.cs +++ b/Assets/WL/Settings/WLGameplaySettings.cs @@ -158,6 +158,36 @@ namespace WL.Settings "몹의 공격 사거리만 줄어 빗나간다. 끄면 판정 박스까지 전부 축소한다(= PD 지시 문자 그대로).")] public bool projectileScaleVisibleOnly = true; + // ── 몬스터 몸 크기 (#816x) ─────────────────────────────────────────────── + // PD 지시 #816 (2026-09-15): "기본적인 몬스터의 크기도 50% 키워줘" → 일반 몹 배율 1.5 + // 적용 지점 = MobActor.Set 이 이미 부르는 WLCollisionTuning.ApplyMob(원본 훅 재사용 · 새 훅 0줄). + // MobActor.cs:87 이 transform.localScale = f_DefaultScale 을 대입한 **직후**(:118)라 + // 매 스폰·풀 재사용마다 테이블 기본값에서 다시 곱한다 = 누적 없음. + // 데이터 표(MonsterList.json f_DefaultScale)는 손대지 않는다 — 런타임 루트 스케일만. + [Header("■ 몬스터 몸 크기 (PD 지시 #816x)")] + [Tooltip("켜면 몬스터 루트 스케일에 아래 배율을 곱한다. 끄면 테이블 f_DefaultScale 그대로(C8 롤백).")] + public bool mobBodyScaleEnabled = true; + + [Tooltip("일반 몹(None)의 몸 크기 배율. 1.5 = PD 지시 '50% 크게'. 1 이면 무변경.")] + public float mobBodyScale = 1.5f; + + [Tooltip("정예 몹(Elite)의 몸 크기 배율. PD 지시는 '기본적인 몬스터'라 기본 1(무변경).")] + public float eliteBodyScale = 1f; + + [Tooltip("보스(Boss)의 몸 크기 배율. PD 지시 범위 밖이라 기본 1(무변경).")] + public float bossBodyScale = 1f; + + [Tooltip("켜면 배율 결과가 보스 판정 경계(bodyScaleBossThreshold)를 넘지 않도록 자른다.\n" + + "원본 ProjectileBase.Check_TargetBoss(:305/308) 와 Actor.Get_ScaleSizeDist(:950) 가 " + + "localScale.x > 2 를 '보스급' 판정에 쓰기 때문이다. 끄면 PD 지시대로 곧이곧대로 ×배율(기본 = 끔).")] + public bool mobBodyScaleKeepUnderBossThreshold = false; + + [Tooltip("위 자르기의 경계값. 원본 코드가 쓰는 2 와 같아야 한다.")] + public float bodyScaleBossThreshold = 2f; + + [Tooltip("경계에서 얼마나 아래로 자를지(여유). 0.02 = 1.98 로 자른다.")] + public float bodyScaleThresholdEpsilon = 0.02f; + // ── 인게임 HUD 오버라이드 (#775) ───────────────────────────────────────── // PD 지시 #775 (2026-09-06): "우측 하단 공격 버튼 및 스킬 아이콘 버튼은 추후 변경해서 사용할 // 예정이므로 지금은 보이지 않게 숨기고, 포트레이트 뷰에 맞게 WL 프로젝트 UI를 배치해줘."