[WL] 설치물 생성 때 맵 깜빡 제거(재굽기 0) · 플레이어 밀어내기(좌판·나무·모닥불·텐트·던전 입구 · 땅 있는 방향만) (#816)

- 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 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-16 23:13:15 +09:00
parent da6854461d
commit 51b0ecc01d
4 changed files with 190 additions and 7 deletions

View File

@ -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;

View File

@ -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<WLDungeonGate>(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<CryingSnow.FarmingIsland.PlayerController>();
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<CharacterController>();
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);
}
/// <summary>열린 타일(FI Island · IsUnlocked)의 중심 — 없으면 원점.</summary>
static Vector3 IslandCenter()
{
var islands = Object.FindObjectsByType<CryingSnow.FarmingIsland.Island>(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<MeshFilter>() == null || isl.GetComponent<MeshFilter>().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<Renderer>(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<Collider>(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;
}
/// <summary>
/// 그 자리 지면 높이 — 설치물(`~WL_FlowInstalls` 밑) 콜라이더 제외 · 요청 높이 +1 m 보다 높거나 0.6 m 보다 낮은 맞힘(바다·경사) 무시.
/// 반환 false = 발 디딜 땅 없음(섬 밖).
/// </summary>
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++)

View File

@ -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[]
{

View File

@ -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;
}
/// <summary>둔덕 봉투 0~1 — 잔디 경계 근처 · 밭 구역 · 평평 자리 둘레에서 0.</summary>
@ -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)