From c313dd88fd1e381d0a859ce646e98158a0e45aea Mon Sep 17 00:00:00 2001 From: swrring Date: Thu, 17 Sep 2026 01:44:09 +0900 Subject: [PATCH] =?UTF-8?q?[WL]=20=EA=B0=80=EA=B2=A9=ED=8C=90=20=3D=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=EC=A0=9C=EA=B3=B1=20=ED=8F=89=EB=A9=B4=20?= =?UTF-8?q?=EB=88=95=ED=9E=98(=EA=B2=BD=EC=82=AC=C2=B7=EB=8B=A4=EB=A6=AC?= =?UTF-8?q?=20=EC=95=84=EC=B9=98=20=EB=B0=80=EC=B0=A9)=20=C2=B7=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=B4=20=EC=B9=B8=20=EC=9E=90=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5(=EB=8B=A4=EB=A6=AC=20=EC=9C=84=20=ED=8C=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20OnActivated=20=EC=A6=89=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98)=20=C2=B7=20=EC=8B=AD=EC=9E=90=20?= =?UTF-8?q?=EA=B0=84=EA=B2=A9(inset=201.3=20=C2=B7=200.45=EB=B0=B0=20?= =?UTF-8?q?=C2=B7=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=202.6=20=C2=B7=20=EC=9E=A5?= =?UTF-8?q?=EC=8B=9D/=ED=8C=90/=EC=86=8C=ED=92=88=20=ED=9A=8C=ED=94=BC)=20?= =?UTF-8?q?=C2=B7=20=EA=B8=80=EC=9E=90=20=EA=B9=8A=EC=9D=B4=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC(=EC=BA=90=EB=A6=AD=ED=84=B0=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EB=A6=BC)=20=C2=B7=20=EC=9E=90=EB=8F=99=20=EC=A7=84=EC=9E=85?= =?UTF-8?q?=20=EB=8B=A8=EC=9D=BC=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20?= =?UTF-8?q?+=208=20s=20=EB=8C=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WLIslandFlowV2: PadSurfaceY 최소제곱 평면 + 테두리 묻힘 0 · IsGroundCollider(소품·벽·난간 제외) · 자리 기억 PlayerPrefs WL_Island_PadSpots · Watch(OnActivated 즉시 PlacePurchasers · 다음 프레임/Baked 뒤 RefitPlaced) · IsSpotBlocked(캠프 장식·던전 문·발판·다른 판·IProp 콜라이더) 가장자리 따라 ±1/±2/끝 비킴 · ApplyOverlay(textAlwaysOnTop) - WLIslandSettings: expansionPadInset 1.3 · Scale 0.45 · TriggerSize 2.6 · Lift 0.05 · TiltMax 20 · padClearRadius 1.4 · expansionPadOverlay 1 = 스프라이트만 깊이 검사 - WLSpriteOverlay.shader: Offset -1,-1 → 0,-2 - WLIslandSave: 세이브 초기화 때 자리 기억 삭제 · WLIslandCampDecor.Made 공개 - WLLeadAutoEnter: 단일 인스턴스(Create/Exists) · 로그인 완료 뒤 EnterDelay 8 s · 재시도 90 s Co-Authored-By: Claude Fable 5.1 --- .../Resources/WL/WLIslandSettings.asset | 11 +- .../WL/Island/Shaders/WLSpriteOverlay.shader | 10 +- Assets/WL/Island/WLIslandCampDecor.cs | 3 + Assets/WL/Island/WLIslandFlowV2.cs | 283 +++++++++++++++++- Assets/WL/Island/WLIslandGateFlow.cs | 2 +- Assets/WL/Island/WLIslandSave.cs | 1 + Assets/WL/Island/WLIslandSettings.cs | 21 +- Assets/WL/Tools/WLLeadAutoEnter.cs | 44 ++- 8 files changed, 344 insertions(+), 31 deletions(-) diff --git a/Assets/WL/Island/Resources/WL/WLIslandSettings.asset b/Assets/WL/Island/Resources/WL/WLIslandSettings.asset index c4c6f1ebf..448d8b4e1 100644 --- a/Assets/WL/Island/Resources/WL/WLIslandSettings.asset +++ b/Assets/WL/Island/Resources/WL/WLIslandSettings.asset @@ -95,11 +95,12 @@ MonoBehaviour: installPushMargin: 0.75 islandPlayerScale: 1.3 expansionPadPlace: 1 - expansionPadInset: 1 - padClearRadius: 1.6 - expansionPadLift: 0.06 - expansionPadTriggerSize: 3.4 - expansionPadScale: 0.5 + expansionPadInset: 1.3 + padClearRadius: 1.4 + expansionPadLift: 0.05 + expansionPadTriggerSize: 2.6 + expansionPadScale: 0.45 + expansionPadTiltMax: 20 expansionPadOverlay: 1 campDecorSnapToGround: 0 applyStartCoin: 1 diff --git a/Assets/WL/Island/Shaders/WLSpriteOverlay.shader b/Assets/WL/Island/Shaders/WLSpriteOverlay.shader index 96cd2f7c3..faca91e3b 100644 --- a/Assets/WL/Island/Shaders/WLSpriteOverlay.shader +++ b/Assets/WL/Island/Shaders/WLSpriteOverlay.shader @@ -1,5 +1,6 @@ -// WLSpriteOverlay.shader — 스프라이트를 **항상 위에**(깊이 무시) 그리는 언릿 · 알파 블렌드 셰이더 -// 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 — 확장 가격판(FI Purchaser 의 Frame/Coin 스프라이트)에 런타임 복사본 재질로 물린다. +// WLSpriteOverlay.shader — 가격판 스프라이트용 언릿 · 알파 블렌드 셰이더(깊이 검사 · 바닥 밀착) +// 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 → 같은 날 「가격 판이 캐릭터를 가리지 않게 · 바닥에 붙은 느낌」. +// 확장 가격판(FI Purchaser 의 Frame/Coin 스프라이트)·우리 발판에 런타임 복사본 재질로 물린다. // SpriteRenderer 가 _MainTex 에 스프라이트 텍스처를 넣어 주고 정점색을 곱한다. 모바일: 텍스처 1장 · 조명 없음. Shader "WL/SpriteOverlay" { @@ -14,10 +15,11 @@ Shader "WL/SpriteOverlay" Cull Off Lighting Off ZWrite Off - // 2026-09-17 PD 「가격판은 캐릭터보다 아래」 — 깊이 검사는 하되(캐릭터·소품이 가린다) 바닥과 겹치지 않게 살짝 당긴다. + // 2026-09-17 PD 「가격판은 캐릭터보다 아래」 — 깊이 검사(캐릭터·소품이 앞이면 가린다). 판은 지형 표본 위 5 cm 에 놓이므로 깊이 당김은 + // 버퍼 단위 2 만(기울기 계수 −1 은 비스듬한 바닥 면에서 크게 당겨져 코인이 캐릭터 발 위로 올라왔다 · 실측). // 풀에 가려지는 문제는 지형 쪽에서 판 자리에 모래를 깔아(풀 없음) 해결한다(WLDemoTerrain 판 자리 모래). ZTest LEqual - Offset -1, -1 + Offset 0, -2 Blend SrcAlpha OneMinusSrcAlpha Pass diff --git a/Assets/WL/Island/WLIslandCampDecor.cs b/Assets/WL/Island/WLIslandCampDecor.cs index c8993104e..0ce4f11d3 100644 --- a/Assets/WL/Island/WLIslandCampDecor.cs +++ b/Assets/WL/Island/WLIslandCampDecor.cs @@ -26,6 +26,9 @@ namespace WL.Island static readonly List s_made = new List(4); + /// 지금 놓여 있는 캠프 장식 루트(들) — 확장 가격판이 텐트·모닥불을 피해 앉을 때 읽는다(2026-09-17). + public static IReadOnlyList Made { get { return s_made; } } + /// 진단 — 마지막으로 놓은 장식 수·중심. public static int DecorSpawned; public static Vector3 LastCenter; diff --git a/Assets/WL/Island/WLIslandFlowV2.cs b/Assets/WL/Island/WLIslandFlowV2.cs index 8106333c6..a5f502e32 100644 --- a/Assets/WL/Island/WLIslandFlowV2.cs +++ b/Assets/WL/Island/WLIslandFlowV2.cs @@ -486,11 +486,15 @@ namespace WL.Island var pos = p.transform.position; // 잠긴 칸 중앙(FI 원래 자리) 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) float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f; p.transform.localScale = Vector3.one * ps; + // 🔴 2026-09-17 PD 「가격판이 경사에 가려지지 않게」 — 판 넓이(반지름 ≈ 2.56×배율) 안 5점의 지형·콜라이더 높이 중 **최고점** 위에 놓고, + // 지형 법선에 맞춰 살짝 눕힌다(최대 expansionPadTiltMax°) → 해변 경사·둔덕 어느 쪽에도 묻히지 않는다. + Quaternion rot; + float gy = PadSurfaceY(new Vector3(target.x, c.y, target.z), c.y, 2.56f * ps, cfg.expansionPadTiltMax, out rot); + p.transform.rotation = rot; p.transform.position = new Vector3(target.x, gy + plateDropLocal * ps + cfg.expansionPadLift, target.z); // 🔴 2026-09-17 — 원본 트리거는 십자(10×5×5 두 개 · ±5 m)라 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작됐다(원본 활성화 경쟁 → 「Bridge (0) inactive」). // 판 크기(expansionPadTriggerSize · 월드 m)만큼의 상자 하나로 줄인다 = 판을 밟아야 산다(우리 발판과 같은 규칙). 로컬 치수는 배율로 역보정. @@ -510,23 +514,25 @@ namespace WL.Island boxes[b].center = new Vector3(0f, -plateDropLocal + 2f / ps, 0f); // 판 높이에서 위로 4 m(월드) } } - if (cfg.expansionPadOverlay != 0) ApplyOverlay(p.gameObject); // 풀·소품에 가려지지 않게 항상 위에 + if (cfg.expansionPadOverlay != 0) ApplyOverlay(p.gameObject, cfg.expansionPadOverlay >= 2); // 1 = 깊이 검사(캐릭터가 가림) · 2 = 글자까지 항상 위 var mk = p.gameObject.AddComponent(); mk.original = pos; PurchasersPlaced++; - LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 안쪽 " + cfg.expansionPadInset.ToString("F1") + " m · 지면 " + gy.ToString("F2") + ")"; + LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 · 옆으로 " + LastSlide.ToString("F1") + " m · 표면 " + gy.ToString("F2") + " · " + LastFitLog + ")"; WLIslandBridge.Log(cfg, LastPlaceLog); } } // ───────────────────────────────────────────────────────────────── - // 🔴 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게 상단에 보이게」 — 스프라이트(Frame·Coin)는 깊이 무시 셰이더 `WL/SpriteOverlay`, - // 가격 글자(TMP)는 TMP 의 Overlay 변형 셰이더로 바꾼 **런타임 복사본** 재질을 물린다(에셋 0줄 · 큐 3100 = 물·거품 뒤). + // 🔴 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게」 → 「가격 판이 캐릭터를 가리지 않게 · 바닥에 붙은 느낌」(같은 날 두 번째 지시) + // 스프라이트(Frame·Coin)는 `WL/SpriteOverlay`(깊이 검사 LEqual + 깊이 오프셋 = 바닥과 z-파이팅 없이 붙고, 캐릭터·소품이 앞에 있으면 가려진다). + // 가격 글자(TMP)는 **원본 재질 그대로**(깊이 검사) — `expansionPadOverlay 2` 일 때만 Overlay 변형(항상 위) 복사본을 물린다(에셋 0줄). + // 풀은 판 자리 모래 원(padClearRadius)이 비우므로 항상-위 그리기가 더는 필요 없다. // ───────────────────────────────────────────────────────────────── static Material s_spriteOverlay; static readonly Dictionary s_tmpOverlay = new Dictionary(); public static int OverlayApplied; - public static void ApplyOverlay(GameObject root) + public static void ApplyOverlay(GameObject root, bool textAlwaysOnTop) { if (root == null) return; var srs = root.GetComponentsInChildren(true); @@ -540,6 +546,7 @@ namespace WL.Island if (s_spriteOverlay != null) for (int i = 0; i < srs.Length; i++) if (srs[i] != null && srs[i].sharedMaterial != s_spriteOverlay) { srs[i].sharedMaterial = s_spriteOverlay; OverlayApplied++; } } + if (!textAlwaysOnTop) return; var texts = root.GetComponentsInChildren(true); for (int i = 0; i < texts.Length; i++) { @@ -563,9 +570,60 @@ namespace WL.Island /// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다. /// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m. /// + // 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ① — 잠긴 칸마다 처음 정한 자리를 기억한다(더 좋은 이웃이 나중에 열려도 · 던전에서 돌아와 + // FI 가 세이브에서 가격판을 다시 만들어도 그대로). PlayerPrefs 에 같이 적어 두고, 세이브 초기화(WLIslandStart) 때 함께 비운다. + const string kSpotPrefsKey = "WL_Island_PadSpots"; + static readonly Dictionary s_spotByTile = new Dictionary(); + static readonly Dictionary s_neighborByTile = new Dictionary(); + static bool s_spotsLoaded; + public static int SpotMemory { get { return s_spotByTile.Count; } } + + public static void ClearSpotMemory() { s_spotByTile.Clear(); s_neighborByTile.Clear(); s_spotsLoaded = true; PlayerPrefs.DeleteKey(kSpotPrefsKey); } + + static void LoadSpots() + { + if (s_spotsLoaded) return; + s_spotsLoaded = true; + var s = PlayerPrefs.GetString(kSpotPrefsKey, ""); + if (string.IsNullOrEmpty(s)) return; + var ci = System.Globalization.CultureInfo.InvariantCulture; + var entries = s.Split(';'); + for (int i = 0; i < entries.Length; i++) + { + var f = entries[i].Split(','); + if (f.Length != 6) continue; + try + { + var key = new Vector3Int(int.Parse(f[0], ci), 0, int.Parse(f[1], ci)); + s_spotByTile[key] = new Vector3(float.Parse(f[2], ci), 0f, float.Parse(f[3], ci)); + s_neighborByTile[key] = new Vector3(float.Parse(f[4], ci), 0f, float.Parse(f[5], ci)); + } + catch (System.Exception) { } + } + } + + static void SaveSpots() + { + var ci = System.Globalization.CultureInfo.InvariantCulture; + var sb = new System.Text.StringBuilder(); + foreach (var kv in s_spotByTile) + { + Vector3 n; if (!s_neighborByTile.TryGetValue(kv.Key, out n)) n = kv.Value; + if (sb.Length > 0) sb.Append(';'); + sb.Append(kv.Key.x.ToString(ci)).Append(',').Append(kv.Key.z.ToString(ci)).Append(',') + .Append(kv.Value.x.ToString("F2", ci)).Append(',').Append(kv.Value.z.ToString("F2", ci)).Append(',') + .Append(n.x.ToString("F2", ci)).Append(',').Append(n.z.ToString("F2", ci)); + } + PlayerPrefs.SetString(kSpotPrefsKey, sb.ToString()); + PlayerPrefs.Save(); + } + public static bool ComputePadSpot(WLIslandSettings cfg, Vector3 lockedPos, FIIsland[] islands, float size, out Vector3 spot, out Vector3 neighborCenter) { spot = lockedPos; neighborCenter = lockedPos; + LoadSpots(); + var key = Vector3Int.RoundToInt(lockedPos); + if (s_spotByTile.TryGetValue(key, out spot)) { neighborCenter = s_neighborByTile[key]; return true; } 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; @@ -585,10 +643,93 @@ namespace WL.Island } if (best == null) return false; neighborCenter = best.transform.position; - spot = neighborCenter + bestDir * (half - Mathf.Clamp(cfg.expansionPadInset, 0.3f, half)); + // 🔴 2026-09-17 PD ③ 「확장 방향에 맞게 · 십자 방향 판끼리 안 겹치게 간격·크기」 + // 자리 = 확장 방향 가장자리 **중앙**, 안쪽 거리 = max(설정, 판 반지름 + 0.15)(판·트리거가 칸 밖으로 안 나간다). + // 그 자리가 텐트·모닥불·던전 문·발판·다른 확장 판·소품과 겹치면 가장자리를 따라 옆으로(±1 · ±2 · 끝) 비킨다. + float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f; + float padR = 2.56f * ps; + float inset = Mathf.Clamp(Mathf.Max(cfg.expansionPadInset, padR + 0.15f), 0.3f, half); + var basePos = neighborCenter + bestDir * (half - inset); + var perp = new Vector3(bestDir.z, 0f, bestDir.x); + float maxSlide = Mathf.Max(0f, half - padR - 0.25f); + float[] cands = { 0f, 1f, -1f, 2f, -2f, maxSlide, -maxSlide }; + spot = basePos; LastSlide = 0f; bool found = false; + for (int k = 0; k < cands.Length; k++) + { + float s = Mathf.Clamp(cands[k], -maxSlide, maxSlide); + var cand = basePos + perp * s; + if (IsSpotBlocked(cfg, cand, padR, key, islands)) continue; + spot = cand; LastSlide = s; found = true; break; + } + if (!found) BlockedSpots++; + s_spotByTile[key] = new Vector3(spot.x, 0f, spot.z); s_neighborByTile[key] = new Vector3(neighborCenter.x, 0f, neighborCenter.z); + SaveSpots(); return true; } + public static float LastSlide; public static int BlockedSpots; + + /// 판 자리가 막혔는가 — 다른 확장 판(잠긴 칸 기억 자리) · 캠프 장식 · 던전 문 · 우리 발판(렌더러 경계) · 콜라이더 있는 섬 소품(동물 제외). + static bool IsSpotBlocked(WLIslandSettings cfg, Vector3 c, float padR, Vector3Int selfKey, FIIsland[] islands) + { + float sep = Mathf.Max(2f * padR, cfg.expansionPadTriggerSize) + 0.4f; + foreach (var kv in s_spotByTile) + { + if (kv.Key == selfKey || IsTileUnlocked(kv.Key, islands)) continue; + float dx = kv.Value.x - c.x, dz = kv.Value.z - c.z; + if (dx * dx + dz * dz < sep * sep) return true; + } + float r = padR + 0.2f; + var made = WLIslandCampDecor.Made; + for (int i = 0; i < made.Count; i++) + { + if (made[i] == null) continue; + var t = made[i].transform; + if (t.childCount == 0) { if (RectHitsCircle(RendererBounds(made[i]), c, r)) return true; continue; } + for (int j = 0; j < t.childCount; j++) if (RectHitsCircle(RendererBounds(t.GetChild(j).gameObject), c, r)) return true; // 텐트·모닥불 하나씩 + } + var gates = Object.FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < gates.Length; i++) if (gates[i] != null && RectHitsCircle(RendererBounds(gates[i].gameObject), c, r)) return true; + var plats = Object.FindObjectsByType(FindObjectsSortMode.None); + for (int i = 0; i < plats.Length; i++) if (plats[i] != null && RectHitsCircle(RendererBounds(plats[i].gameObject), c, r)) return true; + var cols = Physics.OverlapSphere(new Vector3(c.x, c.y + 0.5f, c.z), r, ~0, QueryTriggerInteraction.Collide); + for (int i = 0; i < cols.Length; i++) + { + var col = cols[i]; if (col == null) continue; + if (col.GetComponentInParent() == null) continue; + if (col.GetComponentInParent() != null) continue; + return true; + } + return false; + } + + static bool IsTileUnlocked(Vector3Int key, FIIsland[] islands) + { + if (islands == null) return false; + for (int i = 0; i < islands.Length; i++) + { + var isl = islands[i]; if (isl == null) continue; + if (Vector3Int.RoundToInt(isl.transform.position) == key) return isl.IsUnlocked; + } + return false; + } + + static Bounds RendererBounds(GameObject go) + { + var rs = go.GetComponentsInChildren(false); + if (rs.Length == 0) return new Bounds(go.transform.position, Vector3.zero); + var b = rs[0].bounds; + for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds); + return b; + } + + static bool RectHitsCircle(Bounds b, Vector3 c, float r) + { + if (b.size.x <= 0f && b.size.z <= 0f) return false; + float dx = Mathf.Max(b.min.x - c.x, 0f, c.x - b.max.x), dz = Mathf.Max(b.min.z - c.z, 0f, c.z - b.max.z); + return dx * dx + dz * dz < r * r; + } + /// /// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음). /// 지형이 이 자리에 모래를 깔아 풀을 비운다. @@ -613,6 +754,60 @@ namespace WL.Island return n; } + // ───────────────────────────────────────────────────────────────── + // 🔴 2026-09-17 PD 「가격판이 경사에 가려지지 않게」 + // 판 평면 = 중심 표면 높이 + 지형 법선 기울기(최대 expansionPadTiltMax°). 판 테두리 8점 어디서든 표면(지형·바닥 콜라이더)이 + // 평면보다 높으면 그만큼 평면을 올린다 → 둔덕(안쪽) · 해변 경사(바깥쪽) 어느 쪽에도 판이 묻히지 않는다. + // ───────────────────────────────────────────────────────────────── + public static string LastFitLog = ""; + + static float PadSurfaceY(Vector3 center, float fallbackY, float r, float tiltMax, out Quaternion rot) + { + var dt = Object.FindFirstObjectByType(FindObjectsInactive.Include); + var terrain = dt != null ? dt.TerrainComponent : null; + // 중심 + 테두리 8점의 표면 높이로 최소제곱 평면 y = a·x + b·z + c 를 맞춘다(테두리는 대칭 → Σx² = Σz² = 4r²) → 둔덕·해변 경사·다리 아치를 모두 따라 눕는다. + var os = new Vector3[9]; var ys = new float[9]; + float sum = 0f, sx = 0f, sz = 0f; + for (int k = 0; k < 9; k++) + { + os[k] = k < 8 ? new Vector3(Mathf.Cos(k * Mathf.PI * 0.25f) * r, 0f, Mathf.Sin(k * Mathf.PI * 0.25f) * r) : Vector3.zero; + ys[k] = SurfaceAt(center + os[k], fallbackY, terrain); + sum += ys[k]; sx += ys[k] * os[k].x; sz += ys[k] * os[k].z; + } + float a = sx / (4f * r * r), b = sz / (4f * r * r), c = sum / 9f; + rot = PadTilt(new Vector3(-a, 1f, -b), tiltMax); + float raise = float.MinValue; // 어느 표본도 판 아래에 묻히지 않게 평면을 올린다 + for (int k = 0; k < 9; k++) { float d = ys[k] - (c + (rot * os[k]).y); if (d > raise) raise = d; } + if (raise < 0f) raise = 0f; + LastFitLog = "평면 " + c.ToString("F2") + " 기울기 " + Quaternion.Angle(Quaternion.identity, rot).ToString("F1") + "° 올림 " + raise.ToString("F2"); + return c + raise; + } + + /// 한 점의 표면 높이 = 바닥 콜라이더(레이 · 소품·플레이어·가격판·벽 제외)와 데모 지형 높이 중 큰 값. + static float SurfaceAt(Vector3 p, float fallbackY, Terrain terrain) + { + float h = PadGroundY(p, fallbackY); + if (terrain != null) + { + var td = terrain.terrainData; var tp = terrain.transform.position; + if (td != null && p.x >= tp.x && p.z >= tp.z && p.x <= tp.x + td.size.x && p.z <= tp.z + td.size.z) + { + float th = terrain.SampleHeight(p) + tp.y; + if (th > h) h = th; + } + } + return h; + } + + static Quaternion PadTilt(Vector3 normal, float maxDeg) + { + if (maxDeg <= 0f || normal.sqrMagnitude < 1e-6f) return Quaternion.identity; + var q = Quaternion.FromToRotation(Vector3.up, normal.normalized); + float ang = Quaternion.Angle(Quaternion.identity, q); + if (ang > maxDeg) q = Quaternion.Slerp(Quaternion.identity, q, maxDeg / ang); + return q; + } + 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); @@ -621,11 +816,84 @@ namespace WL.Island { float y = hits[i].point.y; if (y > fallbackY + 1.5f) continue; + if (!IsGroundCollider(hits[i].collider)) continue; if (float.IsNaN(best) || y > best) best = y; } return float.IsNaN(best) ? fallbackY : best; } + /// 바닥으로 치는 콜라이더 — 지형 · 우리 둔덕 콜라이더 · 섬 칸(타일·다리 바닥). 소품·가장자리 벽(세로 얇은 상자)·플레이어·가격판·발판·설치물은 제외(판이 그 위로 올라가면 안 된다). + static bool IsGroundCollider(Collider c) + { + if (c == null || c.isTrigger) return false; + if (c is TerrainCollider) return true; + if (c.gameObject.name.StartsWith("~WL_Relief")) return true; + if (c.GetComponentInParent() == null) return false; + if (c.GetComponentInParent() != null) return false; + var box = c as BoxCollider; + if (box != null) + { + var s = Vector3.Scale(box.size, c.transform.lossyScale); + if (s.y > 0.6f && Mathf.Min(Mathf.Abs(s.x), Mathf.Abs(s.z)) < 3f) return false; // 가장자리 벽(8×2×0.3)·다리 난간(2.6×2×8) + } + return true; + } + + // ───────────────────────────────────────────────────────────────── + // 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ② + // FI 는 칸을 연 순간(OnActivated) 새 가격판을 잠긴 칸 중앙에 만든다 → 3 s 훑기가 오기 전까지 원래 자리(바다 쪽)에 보였다가 옮겨졌다. + // → 칸이 열리는 **그 프레임**에 바로 옮긴다. 지형이 다시 구워지면(솟아오르기 끝) 높이·기울기만 다시 맞춘다(자리 x·z 고정). + // ───────────────────────────────────────────────────────────────── + static readonly HashSet s_watched = new HashSet(); + static bool s_bakedHooked; static WLIslandSettings s_cfgForBaked; + public static int Refits, ActivationPlaces; + + static void Watch(WLIslandSettings cfg, Scene scene) + { + s_watched.RemoveWhere(i => i == null); + var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < islands.Length; i++) + { + var isl = islands[i]; + if (isl == null || s_watched.Contains(isl)) continue; + s_watched.Add(isl); + // 🔴 실측: OnActivated 는 DOScale 완료 콜백 — 같은 프레임의 DOJump 착지가 아직 안 끝나 칸이 ~9 cm 떠 있다 → 자리는 즉시, 높이는 다음 프레임에 한 번 더 맞춘다. + isl.OnActivated += () => + { + if (cfg == null) return; + ActivationPlaces++; + PlacePurchasers(cfg, scene); + var runner = WLIslandBridge.Runner; + if (runner != null) runner.StartCoroutine(Co_RefitNextFrame(cfg)); + }; + } + s_cfgForBaked = cfg; + if (!s_bakedHooked) { s_bakedHooked = true; global::WL.Look.Farm.WLDemoTerrain.Baked += OnTerrainBaked; } + } + + static void OnTerrainBaked() { if (s_cfgForBaked != null) RefitPlaced(s_cfgForBaked); } + + static IEnumerator Co_RefitNextFrame(WLIslandSettings cfg) { yield return null; RefitPlaced(cfg); } + + /// 이미 옮긴 판의 높이·기울기만 지형에 다시 맞춘다(자리는 그대로). + public static void RefitPlaced(WLIslandSettings cfg) + { + if (cfg == null || cfg.expansionPadPlace == 0) return; + var marks = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < marks.Length; i++) + { + var m = marks[i]; if (m == null) continue; + var t = m.transform; + float ps = t.localScale.x > 0.05f ? t.localScale.x : 1f; + float plateDropLocal = 0f; var frame = t.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y; + Quaternion rot; var pos = t.position; + float gy = PadSurfaceY(new Vector3(pos.x, m.original.y, pos.z), m.original.y, 2.56f * ps, cfg.expansionPadTiltMax, out rot); + t.rotation = rot; + t.position = new Vector3(pos.x, gy + plateDropLocal * ps + cfg.expansionPadLift, pos.z); + Refits++; + } + } + static readonly List s_rends = new List(64); static readonly List s_cols = new List(64); @@ -657,6 +925,7 @@ namespace WL.Island { if (cfg == null || cfg.gateFlowEnabled == 0 || cfg.expansionAfterFirstWin == 0) return; + Watch(cfg, scene); // 칸이 열리는 프레임에 바로 옮기기 · 지형 재굽기 뒤 높이 재맞춤 PlacePurchasers(cfg, scene); // 자리 옮기기(숨김 여부와 무관 · 1회) if (IsUnlocked(cfg)) { diff --git a/Assets/WL/Island/WLIslandGateFlow.cs b/Assets/WL/Island/WLIslandGateFlow.cs index 415a45f3f..65083bebf 100644 --- a/Assets/WL/Island/WLIslandGateFlow.cs +++ b/Assets/WL/Island/WLIslandGateFlow.cs @@ -797,7 +797,7 @@ namespace WL.Island float ps = cfg.platformScale > 0.001f ? cfg.platformScale : 1f; AppliedScale = ps; if (ps != 1f) p.transform.localScale = p.transform.localScale * ps; - if (cfg.expansionPadOverlay != 0) WLIslandExpansionGate.ApplyOverlay(p.gameObject); // 2026-09-17 PD — 우리 발판도 풀·소품 위에 보이게(같은 규칙) + if (cfg.expansionPadOverlay != 0) WLIslandExpansionGate.ApplyOverlay(p.gameObject, cfg.expansionPadOverlay >= 2); // 2026-09-17 PD — 우리 발판도 같은 규칙(1 = 깊이 검사 · 캐릭터가 가림) // 🔴 FI 가격판은 프리팹 안에서 **루트보다 앞·아래**에 놓여 있다(실측 2026-09-13: // Frame/Price/Coin 이 전부 y = -0.49 · z = 루트+(-1/0/+1) — 잠긴 섬 자리의 **물 위**에 눕는 판이다). diff --git a/Assets/WL/Island/WLIslandSave.cs b/Assets/WL/Island/WLIslandSave.cs index 1064b66b8..184d8b504 100644 --- a/Assets/WL/Island/WLIslandSave.cs +++ b/Assets/WL/Island/WLIslandSave.cs @@ -88,6 +88,7 @@ namespace WL.Island // WL-816zc — 초기 플로우 단계·확장 개방도 같이 0 으로(시작마다 초기화 대상) if (!string.IsNullOrEmpty(cfg.flowStepPrefsKey)) PlayerPrefs.SetInt(cfg.flowStepPrefsKey, 0); if (!string.IsNullOrEmpty(cfg.expansionUnlockPrefsKey)) PlayerPrefs.SetInt(cfg.expansionUnlockPrefsKey, 0); + WLIslandExpansionGate.ClearSpotMemory(); // 2026-09-17 — 확장 가격판 자리 기억도 세이브와 함께 초기화 PlayerPrefs.Save(); WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지) } diff --git a/Assets/WL/Island/WLIslandSettings.cs b/Assets/WL/Island/WLIslandSettings.cs index e77810895..9c0235b22 100644 --- a/Assets/WL/Island/WLIslandSettings.cs +++ b/Assets/WL/Island/WLIslandSettings.cs @@ -564,22 +564,25 @@ namespace WL.Island [Tooltip("1 = FI 확장 가격판(잠긴 칸 중앙 · 물 위)을 확장 방향 이웃(열린) 칸 가장자리 안쪽 지면 위로 옮긴다(FI 격자·세이브 무변경 · 트리거도 함께). 0 = 원본 자리.")] public int expansionPadPlace = 1; - [Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다. 2026-09-17 PD 「이동 가능한 범위 안에서 최대한 가장자리」 → 2.2 → 1.0(타일 콜라이더 끝 = 걸어갈 수 있는 끝).")] - public float expansionPadInset = 1.0f; + [Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다. 2026-09-17 PD 「이동 가능한 범위 안에서 최대한 가장자리」 → 1.0 → 판이 칸 밖으로 안 나가는 최소값(판 반지름 2.56×배율 + 0.15 = 1.3)이 하한.")] + public float expansionPadInset = 1.3f; [Tooltip("가격판 자리(확장 판·현재 단계 발판)에 지형이 까는 모래 원 반지름(m) — 풀잎이 판을 덮지 않게(판은 깊이 검사로 캐릭터 아래에 그려진다). 0 = 안 깖.")] - public float padClearRadius = 1.6f; + public float padClearRadius = 1.4f; [Tooltip("판을 지면보다 이만큼(m) 띄운다(z-fighting 방지).")] - public float expansionPadLift = 0.06f; + public float expansionPadLift = 0.05f; - [Tooltip("옮긴 가격판의 트리거 한 변(m). 원본 십자(±5 m)는 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작돼 원본 활성화 경쟁 오류가 났다(2026-09-17). 0 = 원본 트리거 그대로.")] - public float expansionPadTriggerSize = 3.4f; + [Tooltip("옮긴 가격판의 트리거 한 변(m). 원본 십자(±5 m)는 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작돼 원본 활성화 경쟁 오류가 났다(2026-09-17). 십자 방향 판(가장자리 중앙 · 서로 4.24 m)끼리 모서리에서 안 겹치려면 < 2×(4 − inset − 1.5) → 2.6. 0 = 원본 트리거 그대로.")] + public float expansionPadTriggerSize = 2.6f; - [Tooltip("옮긴 확장 가격판의 크기 배율 — 2026-09-17 PD 「캠프파이어 발판과 같은 크기」 = platformScale 과 같은 0.5.")] - public float expansionPadScale = 0.5f; + [Tooltip("옮긴 확장 가격판의 크기 배율 — 2026-09-17 PD 「캠프파이어 발판과 같은 크기」 = 0.5 → 「십자 방향 판끼리 겹치지 않게 간격·크기 조정」 0.45.")] + public float expansionPadScale = 0.45f; - [Tooltip("1 = 가격판(프레임·코인 스프라이트·가격 글자)을 깊이 무시 셰이더로 그려 풀·소품에 가려지지 않게 한다(2026-09-17 PD). 0 = 원본 재질.")] + [Tooltip("판을 지형 법선에 맞춰 눕히는 최대 각도(°) — 해변 경사·둔덕에 묻히지 않게(2026-09-17 PD). 0 = 항상 수평.")] + public float expansionPadTiltMax = 20f; + + [Tooltip("가격판 재질: 0 = 원본 · 1 = 프레임·코인 스프라이트를 깊이 검사+깊이 오프셋 셰이더(바닥에 붙고 캐릭터·소품이 앞이면 가려짐 · 2026-09-17 PD 「캐릭터를 가리지 않게 · 바닥에 붙은 느낌」) · 2 = 가격 글자까지 항상 위(옛 방식).")] public int expansionPadOverlay = 1; [Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")] diff --git a/Assets/WL/Tools/WLLeadAutoEnter.cs b/Assets/WL/Tools/WLLeadAutoEnter.cs index 2c555f7df..eebd94eb5 100644 --- a/Assets/WL/Tools/WLLeadAutoEnter.cs +++ b/Assets/WL/Tools/WLLeadAutoEnter.cs @@ -20,19 +20,44 @@ namespace WL.Tools public const string PrefKey = "WL_LeadAutoEnter"; public static string LastLog = ""; public static bool Running; + /// 로그인 완료(AllComplete) 뒤 인게임으로 넘기기까지 기다리는 시간(s) — 사람이 화면을 터치하는 간격. + /// 🔴 2026-09-17 실측: 완료 직후 바로 넘기면 원본 로그인 사슬의 **두 번째** 완료 응답이 타이틀 씬이 내려간 뒤 도착해 + /// `TitleInfo.ServerComplete` 가 지워진 글자를 만져 MissingReferenceException 창이 떴다(재시도 0회에서도 재현). + public static float EnterDelay = 8f; + public static float AllCompleteAt = -1f; + + // 🔴 2026-09-17 실측: 표식(Boot)으로 만든 인스턴스와 프로브가 직접 만든 인스턴스가 **둘 다** DevLogin 을 불러 로그인 사슬이 2개 → + // 두 번째 사슬의 토큰 저장이 씬 전환 뒤 실패해 「서버 에러 · 토큰 저장 실패」 창(원본 모달)이 인게임에 떴다. 인스턴스는 항상 하나만. + static WLLeadAutoEnter s_instance; + public static bool Exists { get { return s_instance != null; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (PlayerPrefs.GetInt(PrefKey, 0) == 0) return; PlayerPrefs.SetInt(PrefKey, 0); PlayerPrefs.Save(); // 1회 소비 + Create(); + } + + /// 프로브가 직접 부를 때도 여기로 — 이미 있으면 만들지 않는다. + public static bool Create() + { + if (s_instance != null) return false; var go = new GameObject("~WL_LeadAutoEnter"); go.hideFlags = HideFlags.DontSave; DontDestroyOnLoad(go); - go.AddComponent(); + s_instance = go.AddComponent(); + return true; } - void Start() { StartCoroutine(Run()); } + void Start() + { + if (s_instance != null && s_instance != this) { Destroy(gameObject); return; } + s_instance = this; + StartCoroutine(Run()); + } + + void OnDestroy() { if (s_instance == this) s_instance = null; } IEnumerator Run() { @@ -51,8 +76,14 @@ namespace WL.Tools bool all = fAll != null && (bool)fAll.GetValue(title); if (all) { + if (AllCompleteAt < 0f) + { + AllCompleteAt = Time.realtimeSinceStartup; + LastLog = "로그인 완료(" + (AllCompleteAt - t0).ToString("F1") + " s) → " + EnterDelay.ToString("F0") + " s 뒤 진입"; + } + if (Time.realtimeSinceStartup - AllCompleteAt < EnterDelay) { yield return new WaitForSeconds(0.5f); continue; } title.OnClick_Screen(); - LastLog = "화면 터치 → 인게임 로드(" + (Time.realtimeSinceStartup - t0).ToString("F1") + " s)"; + LastLog = "로그인 완료 " + (AllCompleteAt - t0).ToString("F1") + " s · 화면 터치 → 인게임 로드(" + (Time.realtimeSinceStartup - t0).ToString("F1") + " s)"; yield return new WaitForSeconds(2f); if (SceneManager.GetActiveScene().name != "Title") break; continue; @@ -61,9 +92,12 @@ namespace WL.Tools 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 마다 다시 시도한다. + // → 타이틀이 보이면 1 s 뒤 바로 호출한다. + // 🔴 2026-09-17 실측: 8 s 마다 다시 부르면 서버 응답(ServerComplete)이 여러 번 오고, 마지막 응답이 인게임 전환 **뒤**에 도착해 + // 원본 TitleInfo 가 지워진 타이틀 글자를 만져 MissingReferenceException 창이 떴다 → 재시도는 실패 표시가 있거나 90 s 무응답일 때만 + // (실측: 개발 로그인 완료까지 40~45 s 걸린다 — 40 s 재시도도 같은 창을 띄웠다). bool first = !loginCalled && Time.realtimeSinceStartup - t0 > 1f; - bool retry = loginCalled && (fail > 0 || Time.realtimeSinceStartup - lastLogin > 8f); + bool retry = loginCalled && (fail > 0 || Time.realtimeSinceStartup - lastLogin > 90f); if (first || retry) { var input = fInput != null ? fInput.GetValue(title) as TMPro.TMP_InputField : null;