From 51b0ecc01da4626a8b71d83f37695b655ea5bbfb Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 16 Sep 2026 23:13:15 +0900 Subject: [PATCH] =?UTF-8?q?[WL]=20=EC=84=A4=EC=B9=98=EB=AC=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=95=8C=20=EB=A7=B5=20=EA=B9=9C=EB=B9=A1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0(=EC=9E=AC=EA=B5=BD=EA=B8=B0=200)=20=C2=B7=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4=20=EB=B0=80=EC=96=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0(=EC=A2=8C=ED=8C=90=C2=B7=EB=82=98=EB=AC=B4?= =?UTF-8?q?=C2=B7=EB=AA=A8=EB=8B=A5=EB=B6=88=C2=B7=ED=85=90=ED=8A=B8=C2=B7?= =?UTF-8?q?=EB=8D=98=EC=A0=84=20=EC=9E=85=EA=B5=AC=20=C2=B7=20=EB=95=85=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EB=B0=A9=ED=96=A5=EB=A7=8C)=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WLDemoTerrain: 굽은 높이·스플랫이 같으면 재대입·인스턴서 재시작 생략 · 밭 구역 0.25 m 고정 + 마지막 유효 구역 유지 · 평평 자리 0.5 m 고정 · 진단 LastInputs · 숨김 목록 중복 제거 - WLIslandFlowV2: PushPlayerOut(설치물 범위+0.6 m 안의 플레이어를 바깥·섬 안쪽·축 순으로 땅 있는 방향으로 이동 · CharacterController 껐다 켬) · 던전 입구에도 적용 · WLIslandCampDecor: 모닥불·텐트에 적용 - 실측: 상인/나무/던전 지불 3회 rebuilds 1 유지 · 좌판 1.76 m · 나무 4.39 m(섬 안쪽) · 모닥불 1.55 m · 입구 1.09 m · 확장 가격판 던전 개방 후 active·렌더러 3/3 Co-Authored-By: Claude Fable 5.1 --- Assets/WL/Island/WLIslandCampDecor.cs | 5 +- Assets/WL/Island/WLIslandFlowV2.cs | 112 ++++++++++++++++++++++++++ Assets/WL/Island/WLIslandSettings.cs | 6 ++ Assets/WL/Look/Farm/WLDemoTerrain.cs | 74 +++++++++++++++-- 4 files changed, 190 insertions(+), 7 deletions(-) diff --git a/Assets/WL/Island/WLIslandCampDecor.cs b/Assets/WL/Island/WLIslandCampDecor.cs index 23c256afb..c8993104e 100644 --- a/Assets/WL/Island/WLIslandCampDecor.cs +++ b/Assets/WL/Island/WLIslandCampDecor.cs @@ -60,7 +60,7 @@ namespace WL.Island // ① 모닥불 = 구역 중앙 var fire = Spawn(cfg, cfg.campFirePrefabPath, root.transform, center, 0f, cfg.campFireScale, "Fire"); - if (fire != null) n++; + if (fire != null) { n++; WLIslandFlowV2.PushPlayerOut(cfg, fire); } // 2026-09-16 PD 「생성물에 끼임」 — 발판 자리(=모닥불 자리)의 플레이어를 밀어낸다 // ② 텐트 = 구역 가장자리 · 불 쪽을 향하게 float r = TentRadius(cfg); @@ -72,7 +72,8 @@ namespace WL.Island Vector3 p = center + new Vector3(Mathf.Sin(rad) * r, 0f, Mathf.Cos(rad) * r); // 텐트가 중앙(불)을 바라보도록 = 바깥 방향의 반대 float faceYaw = yaw + 180f + cfg.campTentFaceYawOffset; - if (Spawn(cfg, cfg.campTentPrefabPath, root.transform, p, faceYaw, cfg.campTentScale, "Tent_" + i) != null) n++; + var tent = Spawn(cfg, cfg.campTentPrefabPath, root.transform, p, faceYaw, cfg.campTentScale, "Tent_" + i); + if (tent != null) { n++; WLIslandFlowV2.PushPlayerOut(cfg, tent); } } DecorSpawned = n; diff --git a/Assets/WL/Island/WLIslandFlowV2.cs b/Assets/WL/Island/WLIslandFlowV2.cs index 4110688d9..694d92e97 100644 --- a/Assets/WL/Island/WLIslandFlowV2.cs +++ b/Assets/WL/Island/WLIslandFlowV2.cs @@ -237,6 +237,12 @@ namespace WL.Island if (d != null) WLIslandBridge.SpawnOneGate(cfg, d, scene); // 이미 있으면 안에서 건너뛴다 // 2026-09-16 PD 「던전 개방 이후에는 기존 에셋과 동일한 순서로 확장 노드」 — 던전 노드가 열리면 원본 확장 가격판을 개방한다. if (d != null && cfg.expansionUnlockOnDungeonOpen != 0) WLIslandExpansionGate.Unlock(cfg); + // 🔴 2026-09-16 PD 「생성된 오브젝트에 플레이어가 끼임」 — 입구(아치)는 발판 자리에 서 있는 플레이어 위로 생긴다 → 밀어낸다. + if (d != null) + { + var gates = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + for (int i = 0; i < gates.Length; i++) if (gates[i] != null && gates[i].Def == d) { PushPlayerOut(cfg, gates[i].gameObject); break; } + } return d != null; } @@ -253,9 +259,115 @@ namespace WL.Island SpawnPrefab(cfg, s.extraPrefabPath, go.transform, pos + rot * s.extraOffset, s.yaw, s.scale, s.keepCollider != 0); } + PushPlayerOut(cfg, go); return true; } + // ───────────────────────────────────────────────────────────────── + // 🔴 2026-09-16 PD 「가판대가 갑자기 생기며 플레이어가 프랍에 끼어버린다 — 생성 위치에 캐릭터가 있으면 밀려나게」 + // 설치물의 월드 범위(렌더러+콜라이더)를 플레이어 반지름만큼 넓힌 상자 안에 플레이어가 있으면, 설치물 중심 → 플레이어 방향으로 + // 상자 밖까지 밀어낸다(CharacterController 는 잠시 끄고 옮긴다 · FI 텔레포트와 같은 방식). 높이 = 그 자리 지면(설치물 자신은 제외). + // ───────────────────────────────────────────────────────────────── + public static int PlayerPushes; + public static string LastPushLog = ""; + + public static void PushPlayerOut(WLIslandSettings cfg, GameObject install) + { + if (cfg.installPushPlayer == 0 || install == null) return; + var pc = Object.FindFirstObjectByType(); + if (pc == null) return; + Bounds b; if (!WorldBounds(install, out b)) return; + float margin = Mathf.Max(0.1f, cfg.installPushMargin); + var box = new Bounds(b.center, b.size); + box.Expand(new Vector3(margin * 2f, 100f, margin * 2f)); + var p = pc.transform.position; + if (!box.Contains(new Vector3(p.x, b.center.y, p.z))) return; + + // 후보 방향: ① 설치물 중심 → 플레이어 ② 섬(열린 타일) 중심 쪽 ③ ±x ④ ±z — **발 디딜 땅이 있는** 첫 방향을 쓴다 + // (2026-09-16 실측: 남쪽 끝 사과나무 자리에서 ①만 쓰면 섬 밖 물로 떨어졌다). + var away = new Vector3(p.x - b.center.x, 0f, p.z - b.center.z); + if (away.sqrMagnitude < 1e-4f) away = -pc.transform.forward; + away.y = 0f; if (away.sqrMagnitude < 1e-6f) away = Vector3.back; away.Normalize(); + var isl = IslandCenter(); var toIsland = new Vector3(isl.x - b.center.x, 0f, isl.z - b.center.z); + if (toIsland.sqrMagnitude < 1e-4f) toIsland = -away; toIsland.Normalize(); + var dirs = new[] { away, toIsland, Vector3.right, Vector3.left, Vector3.forward, Vector3.back }; + Vector3 target = p; float t = 0f; bool ok = false; string how = ""; + for (int k = 0; k < dirs.Length && !ok; k++) + { + var dir = dirs[k]; + float tx = Mathf.Abs(dir.x) > 1e-4f ? ((dir.x > 0f ? box.max.x : box.min.x) - p.x) / dir.x : float.MaxValue; + float tz = Mathf.Abs(dir.z) > 1e-4f ? ((dir.z > 0f ? box.max.z : box.min.z) - p.z) / dir.z : float.MaxValue; + t = Mathf.Min(tx, tz) + 0.15f; + var cand = p + dir * t; + float gy; if (!GroundY(cand, p.y, out gy)) continue; // 땅 없음(섬 밖) → 다음 방향 + cand.y = gy; target = cand; ok = true; how = k == 0 ? "바깥쪽" : k == 1 ? "섬 안쪽" : "축 방향 " + k; + } + if (!ok) { LastPushLog = "밀어낼 땅을 못 찾음 — " + install.name; WLIslandBridge.Log(cfg, LastPushLog); return; } + + var cc = pc.GetComponent(); + if (cc != null) { cc.enabled = false; pc.transform.position = target; cc.enabled = true; } + else pc.transform.position = target; + PlayerPushes++; + LastPushLog = "설치물 " + install.name + " 범위 " + b.size.x.ToString("F1") + "×" + b.size.z.ToString("F1") + " m 안의 플레이어를 " + + p.ToString("F2") + " → " + target.ToString("F2") + " 로 밀어냄(" + t.ToString("F2") + " m · " + how + ")"; + WLIslandBridge.Log(cfg, LastPushLog); + } + + /// 열린 타일(FI Island · IsUnlocked)의 중심 — 없으면 원점. + static Vector3 IslandCenter() + { + var islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + Vector3 sum = Vector3.zero; int n = 0; + for (int i = 0; i < islands.Length; i++) + { + var isl = islands[i]; + if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue; + if (isl.GetComponent() == null || isl.GetComponent().sharedMesh == null) continue; // 더미(발판 타깃) 제외 + sum += isl.transform.position; n++; + } + return n > 0 ? sum / n : Vector3.zero; + } + + static bool WorldBounds(GameObject go, out Bounds b) + { + b = new Bounds(); bool set = false; + var rends = go.GetComponentsInChildren(true); + for (int i = 0; i < rends.Length; i++) + { + if (rends[i] == null || rends[i] is ParticleSystemRenderer || rends[i] is TrailRenderer) continue; + if (!set) { b = rends[i].bounds; set = true; } else b.Encapsulate(rends[i].bounds); + } + var cols = go.GetComponentsInChildren(true); + for (int i = 0; i < cols.Length; i++) + { + if (cols[i] == null || cols[i].isTrigger) continue; + if (!set) { b = cols[i].bounds; set = true; } else b.Encapsulate(cols[i].bounds); + } + return set; + } + + /// + /// 그 자리 지면 높이 — 설치물(`~WL_FlowInstalls` 밑) 콜라이더 제외 · 요청 높이 +1 m 보다 높거나 −0.6 m 보다 낮은 맞힘(바다·경사) 무시. + /// 반환 false = 발 디딜 땅 없음(섬 밖). + /// + static bool GroundY(Vector3 p, float refY, out float groundY) + { + var hits = Physics.RaycastAll(new Vector3(p.x, refY + 3f, p.z), Vector3.down, 10f, ~0, QueryTriggerInteraction.Ignore); + float best = float.NaN; + for (int i = 0; i < hits.Length; i++) + { + var c = hits[i].collider; if (c == null) continue; + var tr = c.transform; bool ours = false; + while (tr != null) { if (tr.name == RootName) { ours = true; break; } tr = tr.parent; } + if (ours) continue; + float y = hits[i].point.y; + if (y > refY + 1f || y < refY - 0.6f) continue; + if (float.IsNaN(best) || y > best) best = y; + } + groundY = float.IsNaN(best) ? refY : best + 0.02f; + return !float.IsNaN(best); + } + static GameObject EnsureRoot(Scene scene) { for (int i = 0; i < s_made.Count; i++) diff --git a/Assets/WL/Island/WLIslandSettings.cs b/Assets/WL/Island/WLIslandSettings.cs index 1bb7d35c7..8f8858a1a 100644 --- a/Assets/WL/Island/WLIslandSettings.cs +++ b/Assets/WL/Island/WLIslandSettings.cs @@ -551,6 +551,12 @@ namespace WL.Island [Tooltip("발판 문구 서식 — {0} = 단계 문구 · {1} = 가격. 🔴 2026-09-16 PD 「비용(예: 60 골드)은 빼라 — 바닥 가격판이 이미 보여 준다」 → 「{0}」 만.")] public string flowLabelFormat = "{0}"; + [Tooltip("1 = 설치물(좌판·나무 등)이 생길 때 그 범위 안에 플레이어가 있으면 범위 밖으로 밀어낸다(2026-09-16 PD 「가판대에 캐릭터가 끼어버림」). 0 = 안 밀어냄.")] + public int installPushPlayer = 1; + + [Tooltip("설치물 범위 바깥으로 얼마나 더(m) 밀어낼지(플레이어 반지름 0.4 + 여유).")] + public float installPushMargin = 0.6f; + [Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")] public WLFlowStepDef[] flowSteps = new WLFlowStepDef[] { diff --git a/Assets/WL/Look/Farm/WLDemoTerrain.cs b/Assets/WL/Look/Farm/WLDemoTerrain.cs index dcbb27474..f0c514133 100644 --- a/Assets/WL/Look/Farm/WLDemoTerrain.cs +++ b/Assets/WL/Look/Farm/WLDemoTerrain.cs @@ -114,6 +114,19 @@ namespace WL.Look.Farm bool zoneValid = global::WL.Island.WLIslandGateFlow.ZoneBoundsValid; var zone = zoneValid ? global::WL.Island.WLIslandGateFlow.ZoneBounds : new Bounds(); + if (zoneValid) + { + // 밭 구역도 0.25 m 격자로 고정(렌더러 바운즈가 cm 단위로 흔들려도 스플랫이 안 바뀌게) + var c = zone.center; var e = zone.extents; + zone = new Bounds(new Vector3(Mathf.Round(c.x * 4f) * 0.25f, c.y, Mathf.Round(c.z * 4f) * 0.25f), + new Vector3(Mathf.Round(e.x * 4f) * 0.5f, e.y * 2f, Mathf.Round(e.z * 4f) * 0.5f)); + _lastZone = zone; _lastZoneValid = true; + } + else if (_lastZoneValid) + { + // 🔴 밭 구역 판정이 잠깐 비면(설치물 생성 뒤 재훑기) 흙 자리가 사라졌다 돌아오며 재굽기·인스턴서 재시작을 부른다 → 마지막 유효 구역을 쓴다 + zone = _lastZone; zoneValid = true; + } // 값이 바뀌면 다시 굽는다(인스펙터에서 만져도 다음 훅에 반영된다). float valSig = cfg.terrainHeightRes * 0.013f + cfg.terrainAlphaRes * 0.007f + cfg.terrainMargin * 7.1f + cfg.terrainMaxSize * 0.11f @@ -131,6 +144,14 @@ namespace WL.Look.Farm + 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; + { + var sbI = new System.Text.StringBuilder(); + sbI.Append("tiles=").Append(_tiles.Count).Append(" tileSig=").Append(tileSig).Append(" valSig=").Append(valSig.ToString("F3")) + .Append(" zone=").Append(zoneValid ? zone.center.ToString("F2") + "/" + zone.size.ToString("F2") : "none").Append(" flats="); + for (int i = 0; i < _flatX.Count; i++) sbI.Append("(").Append(_flatX[i].ToString("F1")).Append(",").Append(_flatZ[i].ToString("F1")).Append(",r").Append(_flatR[i].ToString("F1")).Append(")"); + sbI.Append(" water=").Append(WaterY().ToString("F2")).Append(" topY=").Append(topY.ToString("F2")); + LastInputs = sbI.ToString(); + } HideTops(); // 새로 열린 타일 윗면도 숨긴다(멱등) if (same) { _child.SetActive(true); if (_relief != null) _relief.SetActive(true); return; } @@ -139,6 +160,18 @@ namespace WL.Look.Farm Bake(size, half, topY, zoneValid, zone); LastBakeMs = (float)sw.Elapsed.TotalMilliseconds; + // 🔴 2026-09-16 PD 「상인이 생길 때 섬 일부가 사라졌다 나타남」 — 서명이 바뀌어도 **구운 내용이 같으면** 지형에 다시 쓰지 않고 + // 인스턴서도 껐다 켜지 않는다(SetHeights = 패치 메시 재생성 → 한 프레임 조각 빔 · 인스턴서 재시작 = 풀 한 프레임 소실). + if (LastBakeUnchanged) + { + _child.SetActive(true); + if (_relief != null) _relief.SetActive(true); + _lastTileSig = tileSig; _lastValSig = valSig; _dirty = false; + SkippedRebuilds++; + LastLog = "데모 터레인 — 값 서명은 바뀌었으나 높이·스플랫 동일 → 재대입·인스턴서 재시작 생략(" + LastBakeMs.ToString("F0") + " ms)"; + return; + } + sw.Restart(); string instLog = SetupInstancer(); _child.SetActive(true); // → 인스턴서 OnEnable(SetAlphamaps 뒤) · Terrain 표시 @@ -217,8 +250,10 @@ namespace WL.Look.Farm void AddFlat(float x, float z, float r, ref float sig) { + // 0.5 m 격자로 고정 — 밭 중심·자리가 재훑기마다 cm 단위로 흔들려 둔덕이 매번 미세하게 달라지며 재굽기를 부르던 것을 막는다 + x = Mathf.Round(x * 2f) * 0.5f; z = Mathf.Round(z * 2f) * 0.5f; _flatX.Add(x); _flatZ.Add(z); _flatR.Add(r); - sig += Mathf.Round(x * 4f) * 0.37f + Mathf.Round(z * 4f) * 0.59f + r * 0.11f; + sig += x * 0.37f + z * 0.59f + r * 0.11f; } /// 둔덕 봉투 0~1 — 잔디 경계 근처 · 밭 구역 · 평평 자리 둘레에서 0. @@ -357,7 +392,7 @@ namespace WL.Look.Farm var r = _tileTops[i]; if (r == null || !r.enabled) continue; r.enabled = false; - _hiddenTops.Add(r); + if (!_hiddenTops.Contains(r)) _hiddenTops.Add(r); // FI 가 다시 켠 렌더러를 또 숨겨도 목록은 한 번만 } } @@ -683,12 +718,40 @@ namespace WL.Look.Farm } } - _data.SetHeights(0, 0, heights); - _data.SetAlphamaps(0, 0, alpha); - BuildReliefCollider(reliefAmp > 0f && cfg.terrainReliefCollider != 0, ox, oz, w, l, baseY, span, heights, hRes, half, stepX, stepZ); + // 6) 내용 비교 — 같은 배열이면 지형에 다시 쓰지 않는다(재대입 = 패치 재생성 = 화면 조각 빔) + bool heightsSame = _lastHeights != null && SameArray(_lastHeights, heights); + bool alphaSame = _lastAlpha != null && SameArray(_lastAlpha, alpha); + if (!heightsSame) _data.SetHeights(0, 0, heights); + if (!alphaSame) _data.SetAlphamaps(0, 0, alpha); + if (!heightsSame || _relief == null) BuildReliefCollider(reliefAmp > 0f && cfg.terrainReliefCollider != 0, ox, oz, w, l, baseY, span, heights, hRes, half, stepX, stepZ); + _lastHeights = heights; _lastAlpha = alpha; + LastBakeUnchanged = heightsSame && alphaSame; Bakes++; } + float[,] _lastHeights; + float[,,] _lastAlpha; + Bounds _lastZone; bool _lastZoneValid; + public static bool LastBakeUnchanged; + public static int SkippedRebuilds; + public static string LastInputs = ""; // 진단 — 재굽기 원인 추적(평평 자리 · 밭 구역 · 서명) + + static bool SameArray(float[,] a, float[,] b) + { + if (a.GetLength(0) != b.GetLength(0) || a.GetLength(1) != b.GetLength(1)) return false; + int n0 = a.GetLength(0), n1 = a.GetLength(1); + for (int j = 0; j < n0; j++) for (int i = 0; i < n1; i++) if (Mathf.Abs(a[j, i] - b[j, i]) > 1e-5f) return false; + return true; + } + + static bool SameArray(float[,,] a, float[,,] b) + { + if (a.GetLength(0) != b.GetLength(0) || a.GetLength(1) != b.GetLength(1) || a.GetLength(2) != b.GetLength(2)) return false; + int n0 = a.GetLength(0), n1 = a.GetLength(1), n2 = a.GetLength(2); + for (int j = 0; j < n0; j++) for (int i = 0; i < n1; i++) for (int c = 0; c < n2; c++) if (Mathf.Abs(a[j, i, c] - b[j, i, c]) > 1e-4f) return false; + return true; + } + void EnsureChild() { if (_child == null) @@ -722,6 +785,7 @@ namespace WL.Look.Farm _data.heightmapResolution = hRes; _data.alphamapResolution = aRes; _dataHeightRes = hRes; _dataAlphaRes = aRes; + _lastHeights = null; _lastAlpha = null; // 새 데이터 = 반드시 다시 쓴다 } var layers = _data.terrainLayers; if (layers == null || layers.Length != 2 || layers[0] != cfg.demoGrassLayer || layers[1] != cfg.demoDirtLayer)