[WL] 캐릭터 1.3배·캠프 1.2배 · 다리 칸 = 물 위 원본 다리(지형 제외·재질·가드 오인 수정) · 확장 가격판 이웃 칸 가장자리 위로 · 확장 = 원본 네모 판 숨김 + 새 땅 솟아오르기 1.2 s (#816)

- WLIslandSceneSetup: islandPlayerScale 1.3(표식 1회) · 캠프 campFireScale/campTentScale 2.88 · installPushMargin 0.75
- WLIslandFlowV2/ExpansionGate: PlacePurchasers — FI 가격판을 확장 방향 이웃(열린) 칸 가장자리 안쪽 2.2 m 지면 위로(트리거 동반 · 3 s 훑기·가드에서 1회) · 다리 위면 다리 상판
- WLDemoTerrain: 다리 칸(IsBridge) 발자국 제외 · 열리는 중(scale<1) 칸 즉시 포함 + 원본 렌더러 숨김 · Co_Rise(옛 격자 → 새 격자 보간 · SetHeightsDelayLOD · 인스턴서·거품 미룸)
- WLIslandTileFenceStrip: Bridge 메시는 자르지 않음(목록 0번 유지) · WLIslandGateFlow: 울타리 가드가 「_nofence」를 울타리로 오인해 다리를 매 프레임 끄던 것 수정 · MatchTileMaterials 다리 제외 · WLIslandLook: 다리 본체 재질 원본 유지
- 실측: 플레이어 1.30 · 가격판 (0,16)→(0,0.56,9.8) · (−24,8)→다리 위 (−17.8,1.38,8) · 솟아오르기 59~64 프레임/1.2 s · 다리 mesh=Bridge mat=Palette rendOn · 물 아래 −1.66 · 콘솔 에러 0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-16 23:55:11 +09:00
parent 51b0ecc01d
commit 3e6b94db45
10 changed files with 252 additions and 12 deletions

View File

@ -90,8 +90,13 @@ MonoBehaviour:
campTentZoneScale: 0.85
campTentStartYaw: 35
campTentFaceYawOffset: 0
campFireScale: 2.4
campTentScale: 2.4
campFireScale: 2.88
campTentScale: 2.88
installPushMargin: 0.75
islandPlayerScale: 1.3
expansionPadPlace: 1
expansionPadInset: 2.2
expansionPadLift: 0.06
campDecorSnapToGround: 0
applyStartCoin: 1
startCoin: 10

View File

@ -24,6 +24,8 @@ using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using FIPurchaser = CryingSnow.FarmingIsland.Purchaser;
using FIIsland = CryingSnow.FarmingIsland.Island;
using FIIslandManager = CryingSnow.FarmingIsland.IslandManager;
namespace WL.Island
{
@ -448,11 +450,78 @@ namespace WL.Island
// 우리는 이미 만들어진 가격판의 **렌더러·콜라이더만** 껐다 켠다 = FI 세이브·격자에 흔적 0.
// `SetActive(false)` 를 쓰지 않는 이유는 816g 와 같다(꺼진 오브젝트에서 코루틴이 시작되면 에러).
// ─────────────────────────────────────────────────────────────────────
/// <summary>섬 플레이어 크기를 이미 키웠다는 표식(WLIslandSceneSetup.ApplyPlayerScale · 1회).</summary>
public sealed class WLPlayerScaled : MonoBehaviour { }
/// <summary>FI 확장 가격판을 이웃 칸 가장자리로 옮겼다는 표식(원래 자리 기억).</summary>
public sealed class WLPlacedPurchaser : MonoBehaviour { public Vector3 original; }
public static class WLIslandExpansionGate
{
// ── 진단(프로브·보고가 읽는다 · 실측만)
public static int PurchasersHidden;
public static string LastLog = "";
public static int PurchasersHidden, PurchasersPlaced;
public static string LastLog = "", LastPlaceLog = "";
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「확장 가격판이 맵에 가려져 안 보인다 → 위에 · 확장 방향과 가장 가까운 걸어갈 수 있는 자리」
// FI 는 가격판을 **잠긴 칸 중앙**(이웃 칸 가장자리에서 4 m 바다 쪽 · y 0.5)에 눕힌다 → 우리 해변 경사(모래)가 판의 안쪽 절반을 덮는다.
// → 확장 방향의 이웃(열린) 칸 가장자리에서 `expansionPadInset` 안쪽 지면 위로 옮긴다. FI 의 `activePurchasers` 는 칸 좌표로 기억하므로
// 트랜스폼만 옮겨도 격자·세이브·구매 로직은 그대로고, 트리거 상자(십자 ±5 m)도 함께 옮겨져 걸어가 밟을 수 있다.
// ─────────────────────────────────────────────────────────────────
public static void PlacePurchasers(WLIslandSettings cfg, Scene scene)
{
if (cfg == null || cfg.expansionPadPlace == 0) return;
var mgr = FIIslandManager.Instance;
float size = mgr != null && mgr.IslandSize > 0.1f ? mgr.IslandSize : 8f, half = size * 0.5f;
var all = Object.FindObjectsByType<FIPurchaser>(FindObjectsInactive.Include, FindObjectsSortMode.None);
FIIsland[] islands = null;
for (int i = 0; i < all.Length; i++)
{
var p = all[i];
if (p == null || p.GetComponent<WLPlacedPurchaser>() != null) continue;
if (scene.IsValid() && p.gameObject.scene != scene && p.gameObject.scene != SceneManager.GetActiveScene()) continue;
if (p.GetComponentInParent<WLGoldPlatform>() != null) continue; // 우리 발판
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));
float gy = PadGroundY(target, c.y);
float plateDrop = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDrop = -frame.localPosition.y; // 판 자식들이 루트보다 아래(0.49)
p.transform.position = new Vector3(target.x, gy + plateDrop + cfg.expansionPadLift, target.z);
var mk = p.gameObject.AddComponent<WLPlacedPurchaser>(); mk.original = pos;
PurchasersPlaced++;
LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 안쪽 " + cfg.expansionPadInset.ToString("F1") + " m · 지면 " + gy.ToString("F2") + ")";
WLIslandBridge.Log(cfg, LastPlaceLog);
}
}
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);
float best = float.NaN;
for (int i = 0; i < hits.Length; i++)
{
float y = hits[i].point.y;
if (y > fallbackY + 1.5f) continue;
if (float.IsNaN(best) || y > best) best = y;
}
return float.IsNaN(best) ? fallbackY : best;
}
static readonly List<Renderer> s_rends = new List<Renderer>(64);
static readonly List<Collider> s_cols = new List<Collider>(64);
@ -485,6 +554,7 @@ namespace WL.Island
{
if (cfg == null || cfg.gateFlowEnabled == 0 || cfg.expansionAfterFirstWin == 0) return;
PlacePurchasers(cfg, scene); // 자리 옮기기(숨김 여부와 무관 · 1회)
if (IsUnlocked(cfg))
{
Restore();
@ -539,6 +609,7 @@ namespace WL.Island
yield return new WaitForSeconds(wait);
t += wait;
if (cfg == null || cfg.expansionAfterFirstWin == 0) yield break;
PlacePurchasers(cfg, scene);
if (IsUnlocked(cfg)) { Restore(); yield break; }
HideNow(cfg, scene);
}

View File

@ -512,7 +512,9 @@ namespace WL.Island
if (!hit)
{
var mf = r.GetComponent<MeshFilter>();
if (mf != null && mf.sharedMesh != null && mf.sharedMesh.name.IndexOf("fence", System.StringComparison.OrdinalIgnoreCase) >= 0) hit = true;
// 🔴 2026-09-16 실측: 난간을 잘라낸 복제본 이름이 「…_nofence」라 「fence」에 걸려 **다리(Bridge_nofence)** 가 매 프레임 꺼졌다 → nofence 는 제외
if (mf != null && mf.sharedMesh != null && mf.sharedMesh.name.IndexOf("fence", System.StringComparison.OrdinalIgnoreCase) >= 0
&& mf.sharedMesh.name.IndexOf("nofence", System.StringComparison.OrdinalIgnoreCase) < 0) hit = true;
}
if (!hit) continue;
r.enabled = false; FenceGuardHidden++;
@ -551,6 +553,7 @@ namespace WL.Island
WL.Combat.Stage.StageBarrierVisual.HideImmediate(); // 늦게 페이드 중이던 전투 장벽 그림까지(슬롯 없으면 비용 0)
WLIslandTileFenceStrip.Apply(cfg, scene); // 확장·재구성으로 타일 메시가 바뀌면 다시 잘라낸다(멱등)
if (cfg.hideFarms != 0) { Vector3 _c; HideFarms(cfg, scene, out _c); } // 늦게 생긴 밭의 울타리·소품도
WLIslandExpansionGate.PlacePurchasers(cfg, scene); // 2026-09-16 — 확장 뒤 새로 생긴 FI 가격판도 이웃 칸 가장자리로(표식 있으면 비용 0)
}
}
@ -598,7 +601,7 @@ namespace WL.Island
static void MatchTileMaterials(WLIslandSettings cfg, FIIsland isl)
{
if (cfg.matchNewTileMaterials == 0 || isl == null) return;
if (cfg.matchNewTileMaterials == 0 || isl == null || isl.IsBridge) return; // 다리 타일은 원본 재질 유지(2026-09-16)
var mine = isl.GetComponentsInChildren<Renderer>(true);
if (mine.Length == 0) return;

View File

@ -79,6 +79,7 @@ namespace WL.Island
public static void FixDuplicates(WLIslandSettings cfg, Scene scene)
{
ApplyIslandCameraDistance(cfg, scene); // 2026-09-15 PD 「섬과 던전 모두 좀 더 가까이」 — 섬 카메라 거리(SO)
ApplyPlayerScale(cfg); // 2026-09-16 PD 「캐릭터 30 % 키워」
if (cfg.fixIslandDuplicates == 0) return;
HideWlPc(cfg);
@ -151,6 +152,19 @@ namespace WL.Island
}
}
// ── 2026-09-16 PD 「캐릭터의 크기를 30 % 키워」 — FI 플레이어 트랜스폼 스케일(CharacterController·애니 함께 커진다) · 프리팹 0줄 · 표식으로 1회만
public static int PlayerScaled;
public static void ApplyPlayerScale(WLIslandSettings cfg)
{
if (cfg == null || cfg.islandPlayerScale <= 0f || Mathf.Approximately(cfg.islandPlayerScale, 1f)) return;
var fiPlayer = Object.FindFirstObjectByType<PlayerController>();
if (fiPlayer == null || fiPlayer.GetComponent<WLPlayerScaled>() != null) return;
fiPlayer.transform.localScale = fiPlayer.transform.localScale * cfg.islandPlayerScale;
fiPlayer.gameObject.AddComponent<WLPlayerScaled>();
PlayerScaled++;
WLIslandBridge.Log(cfg, "섬 플레이어 크기 ×" + cfg.islandPlayerScale.ToString("F2") + " → " + fiPlayer.transform.localScale.ToString("F2"));
}
static readonly List<Camera> s_disabledCams = new List<Camera>(4);
static readonly List<AudioListener> s_disabledListeners = new List<AudioListener>(4);

View File

@ -554,8 +554,21 @@ namespace WL.Island
[Tooltip("1 = 설치물(좌판·나무 등)이 생길 때 그 범위 안에 플레이어가 있으면 범위 밖으로 밀어낸다(2026-09-16 PD 「가판대에 캐릭터가 끼어버림」). 0 = 안 밀어냄.")]
public int installPushPlayer = 1;
[Tooltip("설치물 범위 바깥으로 얼마나 더(m) 밀어낼지(플레이어 반지름 0.4 + 여유).")]
public float installPushMargin = 0.6f;
[Tooltip("설치물 범위 바깥으로 얼마나 더(m) 밀어낼지(플레이어 반지름 0.4×1.3 + 여유).")]
public float installPushMargin = 0.75f;
[Tooltip("섬에서 플레이어(FI PlayerController) 크기 배수 — 2026-09-16 PD 「캐릭터 30 % 키워」. 1 = 원본. 섬 준비 때 1회(표식 컴포넌트로 중복 방지).")]
public float islandPlayerScale = 1.3f;
[Header("확장 가격판 자리 (2026-09-16 PD 「맵에 가려짐 → 위에 · 확장 방향과 가장 가까운 걸어갈 수 있는 자리」)")]
[Tooltip("1 = FI 확장 가격판(잠긴 칸 중앙 · 물 위)을 확장 방향 이웃(열린) 칸 가장자리 안쪽 지면 위로 옮긴다(FI 격자·세이브 무변경 · 트리거도 함께). 0 = 원본 자리.")]
public int expansionPadPlace = 1;
[Tooltip("이웃 칸 가장자리에서 안쪽으로 이만큼(m) 들어온 자리에 놓는다(해변 경사·둥근 모서리를 피해 잔디 위).")]
public float expansionPadInset = 2.2f;
[Tooltip("판을 지면보다 이만큼(m) 띄운다(z-fighting 방지).")]
public float expansionPadLift = 0.06f;
[Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")]
public WLFlowStepDef[] flowSteps = new WLFlowStepDef[]

View File

@ -39,6 +39,8 @@ namespace WL.Island
if (mf == null || mf.sharedMesh == null) continue;
var src = mf.sharedMesh;
if (src.name.EndsWith("_nofence")) continue;
// 🔴 2026-09-16 PD 「다리 칸은 물 위 다리로」 — 다리 메시(Bridge · 상판 y≈0.8)는 자르지 않는다(자르면 상판이 사라지고 난간도 다리 것이라 남긴다)
if (isl.IsBridge || src.name.StartsWith("Bridge", System.StringComparison.OrdinalIgnoreCase)) continue;
Mesh dst;
if (!s_cache.TryGetValue(src, out dst)) { dst = Strip(src, cfg.fenceStripMinY); s_cache[src] = dst; }
@ -72,6 +74,7 @@ namespace WL.Island
{
var src = list[i];
if (src == null || src.name.EndsWith("_nofence")) continue;
if (src.name.StartsWith("Bridge", System.StringComparison.OrdinalIgnoreCase)) continue; // 다리 메시(목록 0번)는 원본 그대로
Mesh dst;
if (!s_cache.TryGetValue(src, out dst)) { dst = Strip(src, cfg.fenceStripMinY); s_cache[src] = dst; }
if (dst != null && dst != src) { list[i] = dst; SourceReplaced++; }

View File

@ -404,6 +404,8 @@ MonoBehaviour:
- Level01
- WL_FarmLook
demoTerrainEnabled: 1
expandHideOriginal: 1
expandRiseSeconds: 1.2
demoCloudStrength: 0.5
demoCloudDensity: 0.025
terrainHeightRes: 129

View File

@ -33,6 +33,7 @@
// (Settings null · Mesh/Material null · Scale ≤ 0)은 **켜기 전에** 걸러 낸다.
// 🔴 asmdef 금지(Assembly-CSharp 단일) · `Environment.Instancing` 은 `global::` 로 고정(WL 네임스페이스와 이름 충돌 방지).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using FIIsland = CryingSnow.FarmingIsland.Island;
@ -173,13 +174,13 @@ namespace WL.Look.Farm
}
sw.Restart();
string instLog = SetupInstancer();
string instLog = _deferInstancer ? "인스턴서 미룸(솟아오르기 뒤)" : SetupInstancer();
_child.SetActive(true); // → 인스턴서 OnEnable(SetAlphamaps 뒤) · Terrain 표시
LastInstancerMs = (float)sw.Elapsed.TotalMilliseconds;
_lastTileSig = tileSig; _lastValSig = valSig; _dirty = false;
Rebuilds++;
if (Baked != null) Baked();
if (!_deferInstancer && Baked != null) Baked();
LastLog = "데모 터레인 — 타일 " + _tiles.Count + " · 테두리 변 " + LastEdges + " · 링 " + LastRingPoints
+ " · 범위 " + LastSizeX.ToString("F0") + "×" + LastSizeZ.ToString("F0") + " m(원점 "
@ -371,8 +372,15 @@ namespace WL.Look.Farm
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue;
if (isl == null || !isl.gameObject.activeInHierarchy) continue;
if (scene.IsValid() && isl.gameObject.scene != scene) continue;
// 🔴 2026-09-16 PD 「다리 칸은 물과 섬이 이어지는 모습이어야(낚시)」 — 다리 칸은 지형 발자국에서 뺀다 → 그 칸은 바다·해변으로 남고 원본 다리 메시가 그대로 보인다.
if (isl.IsBridge) continue;
var mfT = isl.GetComponent<MeshFilter>();
if (mfT == null || mfT.sharedMesh == null) continue; // 더미(발판 타깃 Island) 제외
// 🔴 열리는 중(FI Activate: 켜지고 scale 0 → 1 · 1 s)인 칸도 지금부터 발자국에 넣는다 → 원본 네모 판이 튀어오르기 전에 우리 지형이 그 칸을 덮는다(§0-f).
bool activating = !isl.IsUnlocked && isl.transform.localScale.x < 0.999f;
if (!isl.IsUnlocked && !activating) continue;
var p = isl.transform.position;
float top = p.y;
var r = isl.GetComponent<Renderer>(); // 섬 본체 렌더러(FIIsland 와 같은 오브젝트 · SwapMaterials 와 같은 판별)
@ -719,6 +727,8 @@ namespace WL.Look.Farm
}
// 6) 내용 비교 — 같은 배열이면 지형에 다시 쓰지 않는다(재대입 = 패치 재생성 = 화면 조각 빔)
if (_riseCo != null) _riseCancel = true; // 솟아오르는 중에 새로 구우면 그 연출은 즉시 끝낸다(최종 높이로)
_lastOx = ox; _lastOz = oz; _lastW = w; _lastL = l; _lastBaseY = baseY; _lastSpan = span;
bool heightsSame = _lastHeights != null && SameArray(_lastHeights, heights);
bool alphaSame = _lastAlpha != null && SameArray(_lastAlpha, alpha);
if (!heightsSame) _data.SetHeights(0, 0, heights);
@ -731,11 +741,119 @@ namespace WL.Look.Farm
float[,] _lastHeights;
float[,,] _lastAlpha;
float _lastOx, _lastOz, _lastW, _lastL, _lastBaseY, _lastSpan; // 마지막으로 구운 격자(솟아오르기 보간이 옛 격자를 읽는다)
Bounds _lastZone; bool _lastZoneValid;
public static bool LastBakeUnchanged;
public static int SkippedRebuilds;
public static string LastInputs = ""; // 진단 — 재굽기 원인 추적(평평 자리 · 밭 구역 · 서명)
// ─────────────────────────────────────────────────────────────────
// 🔴 §0-f 섬 확장 연출 (2026-09-16 PD 「원본 네모 판 없이 자연스럽게 확장」)
// FI `Island.Activate()` = 켜기 + scale 0 → 1(점프·바운스 1 s) → 그 뒤 IsUnlocked·OnActivated. 원본 네모 판은 이 1 s 동안 튀어오른다.
// → 매 프레임 「켜졌는데 아직 잠김·scale<1」인 칸을 잡아 그 프레임에 렌더러를 숨기고, 우리 지형을 그 칸까지 구운 뒤(최종 높이)
// 새 칸 둘레만 이전 높이 → 새 높이로 `expandRiseSeconds` 동안 보간(SetHeightsDelayLOD · 부분 사각형). 풀 인스턴서·거품은 다 솟은 뒤.
// FI 의 OnActivated(1 s 뒤) → 우리 훅의 재굽기는 내용이 같아 생략된다(§6 비교).
// ─────────────────────────────────────────────────────────────────
public static int Rises, RiseFrames;
public static string LastRiseLog = "";
readonly List<FIIsland> _rising = new List<FIIsland>(4);
FIIsland[] _watch; float _watchNext;
bool _deferInstancer, _riseCancel;
Coroutine _riseCo;
void Update()
{
if (cfg == null || cfg.enabled_ == 0 || cfg.demoTerrainEnabled == 0 || cfg.expandHideOriginal == 0) return;
if (_watch == null || Time.unscaledTime >= _watchNext)
{
_watch = Object.FindObjectsByType<FIIsland>(FindObjectsInactive.Include, FindObjectsSortMode.None);
_watchNext = Time.unscaledTime + 5f;
}
for (int i = 0; i < _watch.Length; i++)
{
var isl = _watch[i];
if (isl == null || isl.IsUnlocked || isl.IsBridge || !isl.gameObject.activeInHierarchy) continue;
if (isl.transform.localScale.x >= 0.999f) continue; // 잠긴 채 아직 비활성화 전(로드 직후)은 scale 1 → 해당 없음
if (_rising.Contains(isl)) continue;
var mf = isl.GetComponent<MeshFilter>(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외
if (gameObject.scene.IsValid() && isl.gameObject.scene != gameObject.scene) continue;
StartRise(isl);
}
for (int i = _rising.Count - 1; i >= 0; i--) if (_rising[i] == null || _rising[i].IsUnlocked) _rising.RemoveAt(i);
}
void StartRise(FIIsland isl)
{
_rising.Add(isl);
var r = isl.GetComponent<Renderer>();
if (r != null && r.enabled) { r.enabled = false; if (!_hiddenTops.Contains(r)) _hiddenTops.Add(r); } // 네모 판은 첫 프레임부터 안 보인다
var oldH = _lastHeights; float oOx = _lastOx, oOz = _lastOz, oW = _lastW, oL = _lastL, oBase = _lastBaseY, oSpan = _lastSpan;
bool animate = cfg.expandRiseSeconds > 0f && oldH != null && _data != null;
_deferInstancer = animate;
_dirty = true;
Rebuild(); // 새 칸 포함 · 최종 높이·스플랫·콜라이더
if (!animate || LastBakeUnchanged || _lastHeights == null) { FinishRise(); return; }
if (_riseCo != null) { _riseCancel = true; StopCoroutine(_riseCo); }
_riseCancel = false;
_riseCo = StartCoroutine(Co_Rise(isl.transform.position, oldH, oOx, oOz, oW, oL, oBase, oSpan, cfg.expandRiseSeconds));
Rises++;
}
void FinishRise()
{
_deferInstancer = false;
_riseCo = null;
if (_child == null || _data == null) return;
SetupInstancer();
_child.SetActive(true);
if (Baked != null) Baked();
}
IEnumerator Co_Rise(Vector3 tilePos, float[,] oldH, float oOx, float oOz, float oW, float oL, float oBase, float oSpan, float seconds)
{
int hRes = _dataHeightRes;
float stepX = _lastW / (hRes - 1), stepZ = _lastL / (hRes - 1);
float ext = TileSize() * 0.5f + Mathf.Max(0.01f, cfg.terrainSlope) * (1f + Mathf.Clamp(cfg.terrainSlopeVar, 0f, 0.8f)) + 3f;
int i0 = Mathf.Clamp(Mathf.FloorToInt((tilePos.x - ext - _lastOx) / stepX), 0, hRes - 1), i1 = Mathf.Clamp(Mathf.CeilToInt((tilePos.x + ext - _lastOx) / stepX), 0, hRes - 1);
int j0 = Mathf.Clamp(Mathf.FloorToInt((tilePos.z - ext - _lastOz) / stepZ), 0, hRes - 1), j1 = Mathf.Clamp(Mathf.CeilToInt((tilePos.z + ext - _lastOz) / stepZ), 0, hRes - 1);
int nx = i1 - i0 + 1, nz = j1 - j0 + 1;
if (nx < 2 || nz < 2) { FinishRise(); yield break; }
var final = new float[nz, nx]; var start = new float[nz, nx]; var cur = new float[nz, nx];
int oRes = oldH.GetLength(0); float oStepX = oW / (oRes - 1), oStepZ = oL / (oRes - 1);
for (int j = 0; j < nz; j++)
for (int i = 0; i < nx; i++)
{
final[j, i] = _lastHeights[j0 + j, i0 + i];
float x = _lastOx + (i0 + i) * stepX, z = _lastOz + (j0 + j) * stepZ;
float gx = (x - oOx) / oStepX, gz = (z - oOz) / oStepZ;
float oldNorm = 0f; // 옛 격자 밖 = 옛 바다 바닥
if (gx >= 0f && gz >= 0f && gx <= oRes - 1 && gz <= oRes - 1)
{
int gi = Mathf.Min((int)gx, oRes - 2), gj = Mathf.Min((int)gz, oRes - 2); float tx = gx - gi, tz = gz - gj;
oldNorm = Mathf.Lerp(Mathf.Lerp(oldH[gj, gi], oldH[gj, gi + 1], tx), Mathf.Lerp(oldH[gj + 1, gi], oldH[gj + 1, gi + 1], tx), tz);
}
float oldY = oBase + oldNorm * oSpan;
start[j, i] = Mathf.Clamp01((oldY - _lastBaseY) / _lastSpan);
}
float t = 0f; int frames = 0;
while (t < seconds && !_riseCancel)
{
float k = SmoothStep01(t / seconds);
for (int j = 0; j < nz; j++) for (int i = 0; i < nx; i++) cur[j, i] = Mathf.Lerp(start[j, i], final[j, i], k);
_data.SetHeightsDelayLOD(i0, j0, cur);
frames++;
yield return null;
t += Time.deltaTime;
}
_data.SetHeightsDelayLOD(i0, j0, final);
_data.SyncHeightmap();
RiseFrames = frames;
LastRiseLog = "솟아오르기 — 칸 " + tilePos.ToString("F0") + " · 부분 " + nx + "×" + nz + " · " + frames + " 프레임 / " + seconds.ToString("F1") + " s" + (_riseCancel ? "(중단)" : "");
cfg.Log(LastRiseLog);
FinishRise();
}
static bool SameArray(float[,] a, float[,] b)
{
if (a.GetLength(0) != b.GetLength(0) || a.GetLength(1) != b.GetLength(1)) return false;

View File

@ -465,7 +465,10 @@ namespace WL.Look.Farm
// Toon 셰이더에는 그 프로퍼티가 없어 갈아끼우면 밭 색이 죽는다 → 원본 유지.
if (cfg.skipSoilRenderers != 0 && r.GetComponent<FISoil>() != null) { s_done.Add(r); continue; }
bool isIslandBody = r.GetComponent<FIIsland>() != null;
var body = r.GetComponent<FIIsland>();
// 🔴 2026-09-16 PD 「다리 칸은 에셋 기준 물 위 다리로」 — 다리 타일 본체는 원본 아틀라스 재질(나무 무늬) 그대로 둔다(잔디 재질로 갈아끼우면 초록 다리가 된다)
if (body != null && body.IsBridge) { s_done.Add(r); continue; }
bool isIslandBody = body != null;
var mats = r.sharedMaterials;
bool changed = false;

View File

@ -667,6 +667,14 @@ namespace WL.Look.Farm
[Tooltip("확장 애니메이션(DOTween 1초)이 끝나기를 기다리는 초.")]
public float rebuildDelaySeconds = 1.4f;
// ── 2026-09-16 PD 「새 맵을 확장할 때 원본 네모 판이 안 보이고 자연스럽게 확장되게」
[Header("§0-f — 섬 확장 연출 (데모 터레인 모드)")]
[Tooltip("1 = 잠긴 칸이 열리는 순간(FI 점프·스케일 애니 첫 프레임)부터 그 칸의 원본 타일 렌더러를 숨기고 우리 지형을 그 칸까지 굽는다(다리 칸 제외). 0 = 원본 애니(네모 판이 튀어오름) 그대로.")]
public int expandHideOriginal = 1;
[Tooltip("새 땅이 바다에서 솟아오르는 시간(초) — 새 칸 둘레 높이맵을 이전 높이 → 새 높이로 보간(SetHeightsDelayLOD · 부분 사각형만). 풀·거품은 다 솟은 뒤 깐다. 0 = 즉시.")]
public float expandRiseSeconds = 1.2f;
// ───────────────────────────────── §2 구름 경계 배치 (816o)
[Header("§2 — 구름 그림자 경계에만 풀을 심는다 (816o · PD 「모바일 부하 없게」)")]
[Tooltip("🔴 되돌리기 스위치 — 0 이면 816n 상태 100 % 복귀(섬 전체에 균일 격자). " +