[WL] 동물 NRE = 가격판 트리거 2개 겹침 → Activate 2회(상자 1개만) · 가격판 가장자리(inset 1.0)·모래 원(풀 없음)·깊이 검사(캐릭터 아래) · 타이틀 자동 진입 WLLeadAutoEnter (#816)

- WLIslandFlowV2/GateFlow: 트리거 상자 하나만(원본 십자 2개가 같은 크기로 겹쳐 OnTriggerEnter 2회 → BeginPurchase 2개 → island.Activate() 2회 → AnimalController.CreateAgent NavMeshAgent 중복 → NRE · PD 화면 실측) · ComputePadSpot(북동남서·땅 우선 고정 순서) 를 배치와 지형이 공유 · CollectPadSpots
- WLDemoTerrain: 가격판 자리(확장 판 + 현재 단계 발판)에 모래 원 padClearRadius 1.6(풀 인스턴스 없음) — 서명에 포함 · 실측 sand@pad 1.00 · 초기 굽기에 포함(재굽기 0)
- WLSpriteOverlay: ZTest LEqual + Offset(캐릭터·소품이 가린다 · 풀은 모래 원으로 해결)
- WLLeadAutoEnter: PlayerPrefs WL_LeadAutoEnter 표식 또는 직접 생성 → DevLogin(1 s 뒤 · 8 s 재시도) → AllComplete → OnClick_Screen · 실측 Play → 섬 53 s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-17 00:46:15 +09:00
parent 28be65ada8
commit edaadc18fa
8 changed files with 200 additions and 22 deletions

View File

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

View File

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

View File

@ -484,22 +484,8 @@ namespace WL.Island
if (islands == null) islands = Object.FindObjectsByType<FIIsland>(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<MeshFilter>(); 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<BoxCollider>();
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
}
}
/// <summary>
/// 잠긴 칸(lockedPos)의 가격판 자리 — 직교 이웃 중 **열린 칸**(다리 포함)을 고정 순서(북·동·남·서 · 땅 우선)로 고르고,
/// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다.
/// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m.
/// </summary>
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<MeshFilter>(); 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;
}
/// <summary>
/// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음).
/// 지형이 이 자리에 모래를 깔아 풀을 비운다.
/// </summary>
public static int CollectPadSpots(WLIslandSettings cfg, List<Vector3> 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<FIIsland>(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<MeshFilter>(); 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);

View File

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

View File

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

View File

@ -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<Vector3> _padSpots = new List<Vector3>(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;

View File

@ -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<WLLeadAutoEnter>();
}
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<TitleInfo>();
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);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 92c4255de55e4f240852e0bfbb005676