diff --git a/Assets/WL/Island/Resources/WL/WLIslandSettings.asset b/Assets/WL/Island/Resources/WL/WLIslandSettings.asset index 55d020ee1..c4c6f1ebf 100644 --- a/Assets/WL/Island/Resources/WL/WLIslandSettings.asset +++ b/Assets/WL/Island/Resources/WL/WLIslandSettings.asset @@ -95,7 +95,8 @@ MonoBehaviour: installPushMargin: 0.75 islandPlayerScale: 1.3 expansionPadPlace: 1 - expansionPadInset: 2.2 + expansionPadInset: 1 + padClearRadius: 1.6 expansionPadLift: 0.06 expansionPadTriggerSize: 3.4 expansionPadScale: 0.5 diff --git a/Assets/WL/Island/Shaders/WLSpriteOverlay.shader b/Assets/WL/Island/Shaders/WLSpriteOverlay.shader index a74b60f57..96cd2f7c3 100644 --- a/Assets/WL/Island/Shaders/WLSpriteOverlay.shader +++ b/Assets/WL/Island/Shaders/WLSpriteOverlay.shader @@ -14,7 +14,10 @@ Shader "WL/SpriteOverlay" Cull Off Lighting Off ZWrite Off - ZTest Always + // 2026-09-17 PD 「가격판은 캐릭터보다 아래」 — 깊이 검사는 하되(캐릭터·소품이 가린다) 바닥과 겹치지 않게 살짝 당긴다. + // 풀에 가려지는 문제는 지형 쪽에서 판 자리에 모래를 깔아(풀 없음) 해결한다(WLDemoTerrain 판 자리 모래). + ZTest LEqual + Offset -1, -1 Blend SrcAlpha OneMinusSrcAlpha Pass diff --git a/Assets/WL/Island/WLIslandFlowV2.cs b/Assets/WL/Island/WLIslandFlowV2.cs index 948518ec7..8106333c6 100644 --- a/Assets/WL/Island/WLIslandFlowV2.cs +++ b/Assets/WL/Island/WLIslandFlowV2.cs @@ -484,22 +484,8 @@ namespace WL.Island if (islands == null) islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); var pos = p.transform.position; // 잠긴 칸 중앙(FI 원래 자리) - FIIsland best = null; float bd = float.MaxValue; - for (int k = 0; k < islands.Length; k++) - { - var isl = islands[k]; - if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue; - var mf = isl.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외 - float dx = isl.transform.position.x - pos.x, dz = isl.transform.position.z - pos.z; - float d = Mathf.Sqrt(dx * dx + dz * dz); - if (d <= size * 1.05f && d < bd) { bd = d; best = isl; } // 직교 이웃(다리 칸 포함 = 다리 위에 놓인다) - } - if (best == null) continue; // 이웃을 못 찾으면 FI 자리 그대로 - - var c = best.transform.position; - var dir = new Vector3(pos.x - c.x, 0f, pos.z - c.z); - dir = Mathf.Abs(dir.x) >= Mathf.Abs(dir.z) ? new Vector3(Mathf.Sign(dir.x), 0f, 0f) : new Vector3(0f, 0f, Mathf.Sign(dir.z)); - var target = c + dir * (half - Mathf.Clamp(cfg.expansionPadInset, 0.5f, half)); + Vector3 target, c; + if (!ComputePadSpot(cfg, pos, islands, size, out target, out c)) continue; // 이웃을 못 찾으면 FI 자리 그대로 float gy = PadGroundY(target, c.y); float plateDropLocal = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y; // 판 자식들이 루트보다 아래(로컬 −0.49) // 🔴 2026-09-17 PD 「확장 가격판이 너무 크다 → 캠프파이어 발판과 같은 크기」 = 우리 발판과 같은 배율(platformScale 0.5 = expansionPadScale) @@ -510,11 +496,16 @@ namespace WL.Island // 판 크기(expansionPadTriggerSize · 월드 m)만큼의 상자 하나로 줄인다 = 판을 밟아야 산다(우리 발판과 같은 규칙). 로컬 치수는 배율로 역보정. if (cfg.expansionPadTriggerSize > 0.1f) { + // 🔴 2026-09-17 PD 화면 실측 「NullReference — FI AnimalController.CreateAgent」 = 같은 칸 `Activate()` 2회 → 동물 NavMeshAgent 2회 추가. + // 원인 = 트리거 상자 **두 개**(원본 십자)가 같은 크기로 겹쳐 OnTriggerEnter 가 2번 → BeginPurchase 코루틴 2개가 둘 다 Activate() 호출. + // → 상자는 **하나만** 남긴다(나머지는 끔). var boxes = p.GetComponents(); float ts = cfg.expansionPadTriggerSize / ps; for (int b = 0; b < boxes.Length; b++) { if (boxes[b] == null) continue; + if (b > 0) { boxes[b].enabled = false; continue; } + boxes[b].enabled = true; boxes[b].size = new Vector3(ts, 4f / ps, ts); boxes[b].center = new Vector3(0f, -plateDropLocal + 2f / ps, 0f); // 판 높이에서 위로 4 m(월드) } @@ -567,6 +558,61 @@ namespace WL.Island } } + /// + /// 잠긴 칸(lockedPos)의 가격판 자리 — 직교 이웃 중 **열린 칸**(다리 포함)을 고정 순서(북·동·남·서 · 땅 우선)로 고르고, + /// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다. + /// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m. + /// + public static bool ComputePadSpot(WLIslandSettings cfg, Vector3 lockedPos, FIIsland[] islands, float size, out Vector3 spot, out Vector3 neighborCenter) + { + spot = lockedPos; neighborCenter = lockedPos; + float half = size * 0.5f; + Vector3[] dirs = { Vector3.forward, Vector3.right, Vector3.back, Vector3.left }; + FIIsland best = null; Vector3 bestDir = Vector3.zero; int bestRank = int.MaxValue; + for (int k = 0; k < islands.Length; k++) + { + var isl = islands[k]; + if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue; + var mf = isl.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외 + var ip = isl.transform.position; + for (int d = 0; d < 4; d++) + { + var expect = lockedPos - dirs[d] * size; // 이웃이 잠긴 칸의 dirs[d] 반대편에 있으면 판은 이웃의 dirs[d] 쪽 가장자리 + if (Mathf.Abs(ip.x - expect.x) > size * 0.1f || Mathf.Abs(ip.z - expect.z) > size * 0.1f) continue; + int rank = d + (isl.IsBridge ? 4 : 0); // 땅 우선 · 북동남서 순 + if (rank < bestRank) { bestRank = rank; best = isl; bestDir = dirs[d]; } + } + } + if (best == null) return false; + neighborCenter = best.transform.position; + spot = neighborCenter + bestDir * (half - Mathf.Clamp(cfg.expansionPadInset, 0.3f, half)); + return true; + } + + /// + /// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음). + /// 지형이 이 자리에 모래를 깔아 풀을 비운다. + /// + public static int CollectPadSpots(WLIslandSettings cfg, List outSpots) + { + if (cfg == null || cfg.expansionPadPlace == 0) return 0; + var mgr = FIIslandManager.Instance; + float size = mgr != null && mgr.IslandSize > 0.1f ? mgr.IslandSize : 8f; + var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + int n = 0; + for (int i = 0; i < islands.Length; i++) + { + var isl = islands[i]; + if (isl == null || isl.IsUnlocked) continue; + var mf = isl.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; + if (isl.Constraint != null && !isl.Constraint.IsUnlocked) continue; + Vector3 spot, c; + if (!ComputePadSpot(cfg, isl.transform.position, islands, size, out spot, out c)) continue; + outSpots.Add(spot); n++; + } + return n; + } + static float PadGroundY(Vector3 p, float fallbackY) { var hits = Physics.RaycastAll(new Vector3(p.x, fallbackY + 5f, p.z), Vector3.down, 20f, ~0, QueryTriggerInteraction.Ignore); diff --git a/Assets/WL/Island/WLIslandGateFlow.cs b/Assets/WL/Island/WLIslandGateFlow.cs index 48a5fb738..415a45f3f 100644 --- a/Assets/WL/Island/WLIslandGateFlow.cs +++ b/Assets/WL/Island/WLIslandGateFlow.cs @@ -827,6 +827,9 @@ namespace WL.Island for (int i = 0; i < boxes.Length; i++) { if (boxes[i] == null) continue; + // 🔴 2026-09-17 — 원본 십자 상자 2개를 같은 크기로 겹치면 OnTriggerEnter 가 2번 나 지불 코루틴이 2개 돌고 Activate() 가 2회 호출된다 → 하나만 남긴다 + if (i > 0) { boxes[i].enabled = false; continue; } + boxes[i].enabled = true; boxes[i].size = new Vector3(s * inv, cfg.platformTriggerHeight * inv, s * inv); boxes[i].center = new Vector3(localCenter.x, cfg.platformTriggerHeight * inv * 0.5f, localCenter.z); } diff --git a/Assets/WL/Island/WLIslandSettings.cs b/Assets/WL/Island/WLIslandSettings.cs index 36fb56e94..e77810895 100644 --- a/Assets/WL/Island/WLIslandSettings.cs +++ b/Assets/WL/Island/WLIslandSettings.cs @@ -564,8 +564,11 @@ namespace WL.Island [Tooltip("1 = FI 확장 가격판(잠긴 칸 중앙 · 물 위)을 확장 방향 이웃(열린) 칸 가장자리 안쪽 지면 위로 옮긴다(FI 격자·세이브 무변경 · 트리거도 함께). 0 = 원본 자리.")] public int expansionPadPlace = 1; - [Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다(해변 경사·둥근 모서리를 피해 잔디 위).")] - public float expansionPadInset = 2.2f; + [Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다. 2026-09-17 PD 「이동 가능한 범위 안에서 최대한 가장자리」 → 2.2 → 1.0(타일 콜라이더 끝 = 걸어갈 수 있는 끝).")] + public float expansionPadInset = 1.0f; + + [Tooltip("가격판 자리(확장 판·현재 단계 발판)에 지형이 까는 모래 원 반지름(m) — 풀잎이 판을 덮지 않게(판은 깊이 검사로 캐릭터 아래에 그려진다). 0 = 안 깖.")] + public float padClearRadius = 1.6f; [Tooltip("판을 지면보다 이만큼(m) 띄운다(z-fighting 방지).")] public float expansionPadLift = 0.06f; diff --git a/Assets/WL/Look/Farm/WLDemoTerrain.cs b/Assets/WL/Look/Farm/WLDemoTerrain.cs index 271ae3787..09fdd9b58 100644 --- a/Assets/WL/Look/Farm/WLDemoTerrain.cs +++ b/Assets/WL/Look/Farm/WLDemoTerrain.cs @@ -141,7 +141,7 @@ namespace WL.Look.Farm + cfg.terrainSlopeVar * 127f + cfg.terrainSlopeVarCell * 131f + cfg.terrainBumpAmp * 137f + cfg.terrainBumpCell * 139f + cfg.terrainSeabedBumpAmp * 149f + cfg.terrainSeabedBumpCell * 151f + cfg.terrainReliefAmp * 157f + cfg.terrainReliefCell * 163f + cfg.terrainReliefEdgeFade * 167f + cfg.terrainReliefFlatRadius * 173f - + cfg.terrainReliefFeather * 179f + cfg.terrainReliefCollider * 181f + CollectFlatSpots(cfg) * 191f + + cfg.terrainReliefFeather * 179f + cfg.terrainReliefCollider * 181f + CollectFlatSpots(cfg) * 191f + CollectPadSpots() * 227f + cfg.terrainBlendInward * 193f + cfg.terrainEdgeNoiseAmp * 197f + cfg.terrainEdgeNoiseCell * 199f + cfg.terrainBumpWaterNotch * 211f + WaterY() * 223f; bool same = !_dirty && _data != null && _child != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f; @@ -249,6 +249,36 @@ namespace WL.Look.Farm return sig; } + // ───────────────────────────────────────────────────────────────── + // 🔴 2026-09-17 PD 「가격판이 풀에 가려지지 않게 · 캐릭터보다는 아래」 — 판 자리(확장 판 = 잠긴 칸의 가격판 자리 · 현재 단계 발판)에 + // 모래 원(padClearRadius)을 깔아 풀잎을 비운다(인스턴서는 잔디 우세 칸에만 심는다). 판 자체는 깊이 검사로 그려 캐릭터가 가린다. + // 자리는 `WLIslandExpansionGate.ComputePadSpot`(가격판 배치와 같은 식)에서 온다 → 판이 생기기 전에도 자리가 정해진다(같은 굽기에서). + // ───────────────────────────────────────────────────────────────── + readonly List _padSpots = new List(8); + public static int LastPadSpots; + + float CollectPadSpots() + { + _padSpots.Clear(); + var icfg = global::WL.Island.WLIslandSettings.Instance; + if (icfg == null || icfg.padClearRadius <= 0.05f) { LastPadSpots = 0; return 0f; } + global::WL.Island.WLIslandExpansionGate.CollectPadSpots(icfg, _padSpots); + if (global::WL.Island.WLIslandFlowV2.Active(icfg)) + { + int step = global::WL.Island.WLIslandFlowV2.Step(icfg), total = global::WL.Island.WLIslandFlowV2.StepCount(icfg); + if (step < total) _padSpots.Add(global::WL.Island.WLIslandFlowV2.PosFor(icfg, step)); // 지금 서 있는(설) 발판 자리 + } + float sig = 0f; + for (int i = 0; i < _padSpots.Count; i++) + { + var p = _padSpots[i]; + p.x = Mathf.Round(p.x * 2f) * 0.5f; p.z = Mathf.Round(p.z * 2f) * 0.5f; _padSpots[i] = p; + sig += p.x * 0.41f + p.z * 0.67f + 1f; + } + LastPadSpots = _padSpots.Count; + return sig + icfg.padClearRadius * 3f; + } + void AddFlat(float x, float z, float r, ref float sig) { // 0.5 m 격자로 고정 — 밭 중심·자리가 재훑기마다 cm 단위로 흔들려 둔덕이 매번 미세하게 달라지며 재굽기를 부르던 것을 막는다 @@ -700,7 +730,9 @@ namespace WL.Look.Farm } } - // 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계) + // 5) 스플랫 — 층0 잔디 = 둥글린 발자국 안(경계 blend 에 걸쳐 1→0) · 층1 모래 = 나머지 + 밭 구역(WLSoftDirt 와 같은 둥근+노이즈 경계) + 가격판 자리 원 + var icfgPad = global::WL.Island.WLIslandSettings.Instance; + float padR = icfgPad != null ? Mathf.Max(0f, icfgPad.padClearRadius) : 0f; var alpha = new float[aRes, aRes, 2]; for (int j = 0; j < aRes; j++) { @@ -720,6 +752,13 @@ namespace WL.Look.Farm float dz = WLSoftDirt.ShapeDist(x, z, zcx, zcz, zhx, zhz, zrad, znoise, zcell); dirt = SmoothStep01((feather * 0.5f - dz) / feather); // WLSoftDirt.BakeMask 와 같은 페이드(1 = 흙 · 0 = 풀) } + // 가격판 자리 모래 원(풀 없음) — 반지름 padClearRadius · 가장자리 0.6 m 페이드 + for (int q = 0; q < _padSpots.Count; q++) + { + float dxp = x - _padSpots[q].x, dzp = z - _padSpots[q].z; + float dp = Mathf.Sqrt(dxp * dxp + dzp * dzp) - padR; + if (dp < 0.3f) dirt = Mathf.Max(dirt, SmoothStep01((0.3f - dp) / 0.6f)); + } float sand = Mathf.Max(1f - grass, dirt); alpha[j, i, 1] = sand; alpha[j, i, 0] = 1f - sand; diff --git a/Assets/WL/Tools/WLLeadAutoEnter.cs b/Assets/WL/Tools/WLLeadAutoEnter.cs new file mode 100644 index 000000000..2c555f7df --- /dev/null +++ b/Assets/WL/Tools/WLLeadAutoEnter.cs @@ -0,0 +1,81 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLLeadAutoEnter.cs — Lead 자동 검증용 타이틀 자동 진입 (원본 0줄 · 표식이 있을 때만) +// +// 2026-09-17 PD 「네가 Play 테스트할 때 타이틀 화면에 계속 대기하지 않도록 진입 프로세스를 개선해」. +// Lead 파이프라인 스크립트가 Play 직전에 PlayerPrefs `WL_LeadAutoEnter = 1` 을 세우면, 타이틀 씬에서 이 컴포넌트가 +// ① 로그인 버튼이 활성화될 때까지 기다렸다가 `OnClick_DevLogin()`(ID 비어 있으면 TestID) +// ② `AllComplete` 가 되면 `OnClick_Screen()`(인게임 로드) +// ③ 로그인 실패(`LoginFail`)면 3 s 뒤 재시도 · 120 s 넘으면 포기 +// 를 대신 한다. 표식은 소비 즉시 지운다(PD 의 일반 Play 에는 영향 0). 원본 `TitleInfo` 의 private 필드는 리플렉션으로 읽기만 한다. +// ───────────────────────────────────────────────────────────────────────────── +using System.Collections; +using System.Reflection; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace WL.Tools +{ + public sealed class WLLeadAutoEnter : MonoBehaviour + { + public const string PrefKey = "WL_LeadAutoEnter"; + public static string LastLog = ""; + public static bool Running; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (PlayerPrefs.GetInt(PrefKey, 0) == 0) return; + PlayerPrefs.SetInt(PrefKey, 0); PlayerPrefs.Save(); // 1회 소비 + var go = new GameObject("~WL_LeadAutoEnter"); + go.hideFlags = HideFlags.DontSave; + DontDestroyOnLoad(go); + go.AddComponent(); + } + + void Start() { StartCoroutine(Run()); } + + IEnumerator Run() + { + Running = true; + float t0 = Time.realtimeSinceStartup; + var bf = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + FieldInfo fActive = typeof(TitleInfo).GetField("activeButtons", bf), fAll = typeof(TitleInfo).GetField("AllComplete", bf), + fFail = typeof(TitleInfo).GetField("LoginFail", bf), fInput = typeof(TitleInfo).GetField("Input_ID", bf); + bool loginCalled = false; float lastLogin = -99f; + while (Time.realtimeSinceStartup - t0 < 120f) + { + if (SceneManager.GetActiveScene().name != "Title") { LastLog = "타이틀 아님(" + SceneManager.GetActiveScene().name + ") — 종료"; break; } + var title = FindFirstObjectByType(); + if (title == null) { yield return new WaitForSeconds(0.5f); continue; } + + bool all = fAll != null && (bool)fAll.GetValue(title); + if (all) + { + title.OnClick_Screen(); + LastLog = "화면 터치 → 인게임 로드(" + (Time.realtimeSinceStartup - t0).ToString("F1") + " s)"; + yield return new WaitForSeconds(2f); + if (SceneManager.GetActiveScene().name != "Title") break; + continue; + } + + bool active = fActive != null && (bool)fActive.GetValue(title); + int fail = fFail != null ? (int)fFail.GetValue(title) : 0; + // 🔴 실측: 스토어 정보 대기 중에는 activeButtons 가 오래 false 인데 OnClick_DevLogin 은 그와 무관하게 동작한다(이전 수동 진입과 동일) + // → 타이틀이 보이면 1 s 뒤 바로 호출하고, 완료가 안 되면 8 s 마다 다시 시도한다. + bool first = !loginCalled && Time.realtimeSinceStartup - t0 > 1f; + bool retry = loginCalled && (fail > 0 || Time.realtimeSinceStartup - lastLogin > 8f); + if (first || retry) + { + var input = fInput != null ? fInput.GetValue(title) as TMPro.TMP_InputField : null; + if (input != null && string.IsNullOrEmpty(input.text)) input.text = "TestID"; + title.OnClick_DevLogin(); + loginCalled = true; lastLogin = Time.realtimeSinceStartup; + LastLog = "DevLogin 호출(" + (lastLogin - t0).ToString("F1") + " s · 버튼 " + active + " · 실패 " + fail + ")"; + } + yield return new WaitForSeconds(0.5f); + } + Running = false; + Destroy(gameObject); + } + } +} diff --git a/Assets/WL/Tools/WLLeadAutoEnter.cs.meta b/Assets/WL/Tools/WLLeadAutoEnter.cs.meta new file mode 100644 index 000000000..bfdbb7a13 --- /dev/null +++ b/Assets/WL/Tools/WLLeadAutoEnter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92c4255de55e4f240852e0bfbb005676 \ No newline at end of file