Land Explorer in HomeCamp with boot loading, journal UI, and CDN publish tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-11-04 16:18:01 +08:00
co-authored by Cursor
parent 9eec364f30
commit 80678ec499
405 changed files with 48053 additions and 420302 deletions
@@ -6,12 +6,12 @@ using YooAsset;
namespace HotUpdate
{
/// <summary>
/// 加载人物角色 CaoCao 的独立预览(地址 CaoCao)。
/// 加载人物角色 Explorer 的独立预览(地址 CaoCao)。
/// </summary>
public sealed class CharacterSpawner : MonoBehaviour
{
public const string PrefabAddress = "CaoCao";
public const string EditorPrefabPath = "Assets/Bundles/Characters/Human/CaoCao/CaoCao.prefab";
public const string PrefabAddress = "Explorer";
public const string EditorPrefabPath = "Assets/Bundles/Characters/Human/Explorer/Explorer.prefab";
public const string ArtPackageName = "Art";
BootContext _ctx;
@@ -58,7 +58,7 @@ namespace HotUpdate
if (prefab == null)
{
Debug.LogError("[CharacterSpawner] CaoCao prefab not found");
Debug.LogError("[CharacterSpawner] Explorer prefab not found");
yield break;
}
@@ -66,7 +66,7 @@ namespace HotUpdate
Destroy(_instance);
_instance = Instantiate(prefab);
_instance.name = "CaoCao_Runtime";
_instance.name = "Explorer_Runtime";
var y = GroundSit.Height(Vector3.zero, null);
_instance.transform.position = new Vector3(0f, y, 0f);
DontDestroyOnLoad(_instance);
@@ -0,0 +1,229 @@
using System;
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>
/// 3D 营地主角探险家控制器:响应点击路面/建筑、平滑移动至目标位置并驱动待机与行走动画,支持到达事件与地面指示光圈。
/// </summary>
public sealed class CampExplorerController : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 4.8f;
public float turnSpeed = 720f;
public float stopDistance = 0.35f;
[Header("Ground Boundaries")]
public float minX = -10f;
public float maxX = 10f;
public float minZ = -14f;
public float maxZ = 12f;
Animator _animator;
Vector3 _targetPosition;
bool _isMoving;
int _animParamIsMoving;
int _animParamSpeed;
Action _onArrival;
Vector3? _targetFacing;
GameObject _indicator;
public bool IsMoving => _isMoving;
public Vector3 TargetPosition => _targetPosition;
public Animator ExplorerAnimator => _animator;
void Awake()
{
EnsureInit();
EnsureIndicator();
}
public void EnsureInit()
{
if (_animator == null) _animator = GetComponent<Animator>();
if (_animParamIsMoving == 0) _animParamIsMoving = Animator.StringToHash("isMoving");
if (_animParamSpeed == 0) _animParamSpeed = Animator.StringToHash("speed");
}
public void EnsureIndicator()
{
if (_indicator != null) return;
Transform parentTf = transform.parent != null ? transform.parent : transform;
var existing = parentTf.Find("ClickIndicator");
if (existing != null)
{
_indicator = existing.gameObject;
return;
}
_indicator = new GameObject("ClickIndicator");
_indicator.transform.SetParent(parentTf, false);
_indicator.layer = gameObject.layer;
var mf = _indicator.AddComponent<MeshFilter>();
var mr = _indicator.AddComponent<MeshRenderer>();
mf.sharedMesh = CreateRingMesh(0.85f, 1.25f, 32);
var smr = GetComponentInChildren<SkinnedMeshRenderer>();
if (smr != null && smr.sharedMaterial != null)
{
mr.sharedMaterial = smr.sharedMaterial;
}
_indicator.SetActive(false);
}
static Mesh CreateRingMesh(float innerRadius, float outerRadius, int segments)
{
var mesh = new Mesh { name = "RingMesh" };
var verts = new Vector3[segments * 2];
var uvs = new Vector2[segments * 2];
var tris = new int[segments * 6];
float angleStep = 360f / segments * Mathf.Deg2Rad;
for (int i = 0; i < segments; i++)
{
float a = i * angleStep;
float cos = Mathf.Cos(a);
float sin = Mathf.Sin(a);
verts[i * 2] = new Vector3(cos * innerRadius, 0.04f, sin * innerRadius);
verts[i * 2 + 1] = new Vector3(cos * outerRadius, 0.04f, sin * outerRadius);
uvs[i * 2] = new Vector2((float)i / segments, 0);
uvs[i * 2 + 1] = new Vector2((float)i / segments, 1);
int next = (i + 1) % segments;
int baseIdx = i * 6;
tris[baseIdx] = i * 2;
tris[baseIdx + 1] = next * 2;
tris[baseIdx + 2] = i * 2 + 1;
tris[baseIdx + 3] = next * 2;
tris[baseIdx + 4] = next * 2 + 1;
tris[baseIdx + 5] = i * 2 + 1;
}
mesh.vertices = verts;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateNormals();
return mesh;
}
/// <summary>
/// 指挥探险家移动到指定世界坐标(单参数重载,保持反射与旧接口兼容)。
/// </summary>
public void MoveTo(Vector3 worldPos) => MoveTo(worldPos, null, null);
/// <summary>
/// 指挥探险家移动到指定世界坐标(自动对齐路面并做边界 Clamp)。
/// 可选指定到达回调与到达时的最终朝向。
/// </summary>
public void MoveTo(Vector3 worldPos, Action onArrival, Vector3? targetFacing = null)
{
EnsureInit();
EnsureIndicator();
float clampedX = Mathf.Clamp(worldPos.x, minX, maxX);
float clampedZ = Mathf.Clamp(worldPos.z, minZ, maxZ);
// 避开中央喷泉碰撞体中心
Vector2 fountainCenter = new Vector2(0f, 0.8f);
Vector2 target2D = new Vector2(clampedX, clampedZ);
if (Vector2.Distance(target2D, fountainCenter) < 2.3f)
{
Vector2 dir = (target2D - fountainCenter).normalized;
if (dir.sqrMagnitude < 0.001f) dir = Vector2.down;
target2D = fountainCenter + dir * 2.35f;
clampedX = target2D.x;
clampedZ = target2D.y;
}
_targetPosition = new Vector3(clampedX, transform.position.y, clampedZ);
_onArrival = onArrival;
_targetFacing = targetFacing;
_isMoving = true;
if (_animator != null)
{
_animator.SetBool(_animParamIsMoving, true);
_animator.SetFloat(_animParamSpeed, 1.0f);
}
if (_indicator != null)
{
_indicator.transform.position = _targetPosition;
_indicator.SetActive(true);
}
}
public void Stop()
{
EnsureInit();
_isMoving = false;
_targetPosition = transform.position;
if (_animator != null)
{
_animator.SetBool(_animParamIsMoving, false);
_animator.SetFloat(_animParamSpeed, 0f);
}
if (_targetFacing.HasValue && _targetFacing.Value.sqrMagnitude > 0.001f)
{
transform.rotation = Quaternion.LookRotation(_targetFacing.Value.normalized, Vector3.up);
}
if (_indicator != null)
{
_indicator.SetActive(false);
}
var callback = _onArrival;
_onArrival = null;
_targetFacing = null;
callback?.Invoke();
}
void Update()
{
if (!_isMoving) return;
Vector3 toTarget = _targetPosition - transform.position;
toTarget.y = 0;
float dist = toTarget.magnitude;
if (dist <= stopDistance)
{
Stop();
return;
}
// 平滑转向目标
if (toTarget.sqrMagnitude > 0.0001f)
{
Quaternion targetRot = Quaternion.LookRotation(toTarget.normalized, Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRot, turnSpeed * Time.deltaTime);
}
// 平滑移动前进
float step = Mathf.Min(moveSpeed * Time.deltaTime, dist);
transform.position += toTarget.normalized * step;
// 光圈轻微脉冲缩放
if (_indicator != null && _indicator.activeSelf)
{
float pulse = 1.0f + 0.12f * Mathf.Sin(Time.time * 6.5f);
_indicator.transform.localScale = new Vector3(pulse, 1.0f, pulse);
}
}
void OnDestroy()
{
if (_indicator != null)
{
Destroy(_indicator);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 56e097e35ae00204cb39f05183b5c317
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,153 @@
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>
/// 营地伙伴闲逛控制器:在指定区域内随机漫步、呼吸待机,并在探险家靠近时好奇转身打招呼。
/// </summary>
public sealed class CampPetWanderer : MonoBehaviour
{
[Header("Wander Settings")]
public Vector3 wanderCenter;
public float wanderRadius = 2.8f;
public float moveSpeed = 1.6f;
public float turnSpeed = 480f;
public float stopDistance = 0.35f;
[Header("Timing")]
public float minIdleTime = 2.5f;
public float maxIdleTime = 6.0f;
[Header("Social")]
public float greetingDistance = 4.8f;
[Header("Model Facing Convention")]
[Tooltip("既有伙伴动物模型正面为局部 -Z(2026-10-24 之前旧资产规范),开启后使正面朝向移动方向")]
public bool modelFacesMinusZ = true;
Animator _animator;
Transform _playerTransform;
Vector3 _targetPosition;
bool _isMoving;
float _idleTimer;
int _animParamMotion;
public bool IsMoving => _isMoving;
public Vector3 TargetPosition => _targetPosition;
public Animator PetAnimator => _animator;
void Awake()
{
EnsureInit();
}
public void EnsureInit()
{
if (_animator == null) _animator = GetComponentInChildren<Animator>();
if (_animParamMotion == 0) _animParamMotion = Animator.StringToHash("Motion");
if (wanderCenter == Vector3.zero) wanderCenter = transform.position;
_targetPosition = transform.position;
if (_idleTimer <= 0f) _idleTimer = Random.Range(minIdleTime * 0.5f, maxIdleTime);
}
void Start()
{
var explorer = GameObject.Find("Explorer");
if (explorer != null) _playerTransform = explorer.transform;
}
/// <summary>
/// 指挥伙伴移动到指定目标点。
/// </summary>
public void MoveTo(Vector3 target)
{
EnsureInit();
_targetPosition = target;
_isMoving = true;
if (_animator != null) _animator.SetInteger(_animParamMotion, 1);
}
/// <summary>
/// 停步并进入待机状态。
/// </summary>
public void Stop()
{
EnsureInit();
_isMoving = false;
_targetPosition = transform.position;
_idleTimer = Random.Range(minIdleTime, maxIdleTime);
if (_animator != null) _animator.SetInteger(_animParamMotion, 0);
}
void Update()
{
float dt = Time.deltaTime;
// 探险家靠近时好奇注视
if (_playerTransform != null)
{
Vector3 toPlayer = _playerTransform.position - transform.position;
toPlayer.y = 0;
if (toPlayer.sqrMagnitude < greetingDistance * greetingDistance)
{
if (_isMoving) Stop();
if (toPlayer.sqrMagnitude > 0.001f)
{
Vector3 lookDir = modelFacesMinusZ ? -toPlayer.normalized : toPlayer.normalized;
Quaternion facePlayer = Quaternion.LookRotation(lookDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, facePlayer, turnSpeed * dt);
}
return;
}
}
if (_isMoving)
{
Vector3 toTarget = _targetPosition - transform.position;
toTarget.y = 0;
float dist = toTarget.magnitude;
if (dist <= stopDistance)
{
Stop();
return;
}
// 平滑转向并前进(结合模型正向规范)
if (toTarget.sqrMagnitude > 0.0001f)
{
Vector3 lookDir = modelFacesMinusZ ? -toTarget.normalized : toTarget.normalized;
Quaternion rot = Quaternion.LookRotation(lookDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, rot, turnSpeed * dt);
}
float step = Mathf.Min(moveSpeed * dt, dist);
transform.position += toTarget.normalized * step;
}
else
{
_idleTimer -= dt;
if (_idleTimer <= 0f)
{
PickNextDestination();
}
}
}
void PickNextDestination()
{
Vector2 rand2D = Random.insideUnitCircle * wanderRadius;
Vector3 dest = wanderCenter + new Vector3(rand2D.x, 0, rand2D.y);
// 避开喷泉中心
Vector2 fountainCenter = new Vector2(0f, 0.8f);
if (Vector2.Distance(new Vector2(dest.x, dest.z), fountainCenter) < 2.3f)
{
_idleTimer = 1.0f;
return;
}
MoveTo(dest);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7b7c725436994f74686b18726434446f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -6,18 +6,25 @@ namespace HotUpdate.Gameplay
public sealed class ExplorerLocomotion : MonoBehaviour
{
Animator _animator;
bool _hasMotion;
Vector3 _last;
void Awake()
{
_animator = GetComponentInChildren<Animator>();
if (_animator != null) _animator.applyRootMotion = false;
if (_animator != null)
{
_animator.applyRootMotion = false;
foreach (var parameter in _animator.parameters)
if (parameter.name == "Motion" && parameter.type == AnimatorControllerParameterType.Int)
_hasMotion = true;
}
_last = transform.position;
}
void LateUpdate()
{
if (_animator == null) return;
if (_animator == null || !_hasMotion) return;
Vector3 delta = transform.position - _last;
_last = transform.position;
float speed = new Vector2(delta.x, delta.z).magnitude / Mathf.Max(Time.deltaTime, 0.0001f);
@@ -59,6 +59,16 @@ namespace HotUpdate.Gameplay
_hud.OnReconnectClicked += () => _net?.RequestReconnect();
_hud.OnHomeActionClicked += ShowHomeFeature;
}
// Reveal the HUD after its camp is ready, avoiding a brief illustrated fallback.
var camp = hudGo.GetComponentInChildren<HomeCampPresenter>(true);
float campDeadline = Time.realtimeSinceStartup + 90f;
while (camp != null && camp.Loading && Time.realtimeSinceStartup < campDeadline)
yield return null;
if (camp != null && camp.Loading)
{
if (_ctx != null) _ctx.MainUiError = "营地加载超时,请重新进入游戏";
yield break;
}
if (_ctx != null) _ctx.MainUiReady = true;
Debug.Log("[UI] Main HUD ready");
@@ -143,7 +153,9 @@ namespace HotUpdate.Gameplay
case "Rank": title = "冒险排行"; break;
case "Friends": title = "好友"; break;
case "Mail": title = "小镇邮件"; break;
case "Rewards": title = "宝箱奖励"; break;
case "Fountain": case "Rewards": title = "宝箱奖励"; break;
case "MainHouse": case "Camp": title = "营地大厅"; break;
case "Stall": title = "集市货摊"; break;
case "Bag": title = "背包"; break;
case "Adventure": case "Battle": title = "开始冒险"; break;
case "Shop": title = "小镇商店"; break;
@@ -691,7 +691,7 @@ namespace HotUpdate.Gameplay
var mails = _game.State.mails ?? new PrototypeMail[0];
int pages = Math.Max(1, (mails.Length + 4) / 5);
_mailPage = Mathf.Clamp(_mailPage, 0, pages - 1);
_page.Text("MailWallet", "金币 " + _game.State.coins + " 糖果 " + _game.State.candy + " 弹珠 " + _game.State.balls + " 券 " + _game.State.tickets);
_page.Text("MailWallet", mails.Length + " 封来信 · " + mails.Count(m => m.CanClaim(ServerNow)) + " 份待领附件");
_page.Text("MailPage", (_mailPage + 1) + " / " + pages + " 页 共 " + mails.Length + " 封");
_page.Node("Txt_MailEmpty").gameObject.SetActive(mails.Length == 0);
for (int row = 0; row < 5; row++)
@@ -700,8 +700,11 @@ namespace HotUpdate.Gameplay
_page.Button("Mail" + row).gameObject.SetActive(exists);
if (!exists) continue;
var m = mails[index];
_page.Text("MailTitle" + row, (_mailSelected == m.mailId ? "▶ " : "") + m.title);
_page.Text("MailTitle" + row, (_mailSelected == m.mailId ? "• " : "") + m.title);
_page.Text("MailStatus" + row, (m.read ? "已读" : "未读") + " · " + MailStatus(m));
_page.Node("MailUnread" + row).gameObject.SetActive(!m.read);
_page.Button("Mail" + row).GetComponent<Image>().color = _mailSelected == m.mailId
? new Color(.79f, .92f, 1f) : Color.white;
_page.Button("Mail" + row).interactable = !Blocked;
}
_page.Button("MailPrev").interactable = !Blocked && _mailPage > 0;
@@ -716,6 +719,12 @@ namespace HotUpdate.Gameplay
int available = mails.Count(m => m.CanClaim(ServerNow));
_page.Text("ClaimAllMail", available == 0 ? "暂无可领取附件" : "一键领取 · " + Math.Min(20, available) + " 封");
_page.Button("ClaimAllMail").interactable = !Blocked && available > 0;
// Authored vertical layout collapses absent rows; a short inbox does
// not leave blank placeholder space above the selected letter.
var stack = (RectTransform)_page.Node("MailStack");
LayoutRebuilder.ForceRebuildLayoutImmediate(stack);
((RectTransform)_page.Node("Content_Page")).SetSizeWithCurrentAnchors(
RectTransform.Axis.Vertical, -stack.anchoredPosition.y + stack.rect.height + 36);
}
void ShowMailReward(PrototypeReply reply)
{
@@ -885,30 +894,60 @@ namespace HotUpdate.Gameplay
void RefreshTasks()
{
var s = _game.State; long seconds = Math.Max(0, _reply.daily.resetAt - _serverUnix - (long)(Time.realtimeSinceStartupAsDouble - _receivedAt));
_page.Text("TaskSummary", "每日目标 · 已领取 " + s.dailyClaimed.Sum() + " / 3\n" + (seconds == 0 ? "正在确认新一天的进度…" : "距重置 " + seconds / 3600 + "小时" + seconds % 3600 / 60 + "分 · UTC+8"));
_page.Text("GoalInfo", "首次通过1-1 · 弹珠 " + _config.economy.goalBalls + "\n" + (s.goalClaimed ? "奖励已领取" : s.cleared ? "目标已完成,可以领取" : "发射弹珠、培养伙伴并守住营地"));
_page.Text("ClaimGoal", s.goalClaimed ? "已领取" : s.cleared ? "领取奖励" : "未完成");
_page.Button("ClaimGoal").interactable = !Blocked && s.cleared && !s.goalClaimed;
_page.Button("GoGoal").interactable = !Blocked && !s.goalClaimed;
int claimedCount = s.dailyClaimed.Sum();
_page.Text("TaskSummary", claimedCount + " / 3 份奖励已领取");
_page.Text("TaskReset", seconds == 0 ? "正在更新今日委托…" : "距更新 " + seconds / 3600 + "小时" + seconds % 3600 / 60 + "分 · 每日零点");
_page.Text("GoalInfo", s.goalClaimed ? "首次通关 1-1\n营地初守已完成" : "首次通关 1-1\n成长奖励永久保留");
_page.Text("GoalReward", "弹珠 ×" + _config.economy.goalBalls);
SetTaskAction("ClaimGoal", "GoGoal", "Txt_GoalDone", s.goalClaimed, s.cleared, true);
for (int i = 0; i < 3; i++)
{
var task = _config.daily.tasks[i]; bool claimed = s.dailyClaimed[i] == 1, ready = s.dailyProgress[i] >= task.target;
_page.Text("DailyName" + i, task.title);
_page.Text("DailyInfo" + i, "进度 " + s.dailyProgress[i] + " / " + task.target + " · " + DailyRewardText(task.reward) + "\n" + (i == 2 ? !CampUnlocked(_config.camp.restUnlockStage) ? "通过1-3开放伙伴休息区" : "在营地与已拥有伙伴打招呼" : i == 1 ? "正常结束计数 · 主动离开不计入" : "弹珠落入奖励区并结算后计入"));
_page.Text("ClaimDaily" + i, claimed ? "已领取" : ready ? "领取奖励" : "未完成");
_page.Button("ClaimDaily" + i).interactable = !Blocked && !claimed && ready && seconds > 0;
_page.Button("GoDaily" + i).interactable = !Blocked && !claimed;
_page.Text("DailyInfo" + i, claimed ? "今日奖励已放入背包" : ready ? "委托完成,收下这份奖励吧" :
i == 2 ? !CampUnlocked(_config.camp.restUnlockStage) ? "通关 1-3 后开放伙伴互动" : "在营地与伙伴打个招呼" :
i == 1 ? "完成结算,主动离开不计入" : "弹珠落入奖励区并完成结算");
_page.Text("DailyReward" + i, DailyRewardText(task.reward));
_page.Text("DailyCount" + i, Math.Min(s.dailyProgress[i], task.target) + " / " + task.target);
_page.Text("GoDaily" + i, i == 0 ? "去发射" : i == 1 ? "去冒险" : CampUnlocked(_config.camp.restUnlockStage) ? "去互动" : "去解锁");
SetTaskAction("ClaimDaily" + i, "GoDaily" + i, "Txt_DailyDone" + i, claimed, ready, seconds > 0);
SetTaskProgress("DailyProgress" + i, task.target > 0 ? (float)s.dailyProgress[i] / task.target : 0);
_page.Node("DailyMilestone" + i).GetComponent<Image>().color = i < claimedCount
? Color.white : new Color(.55f, .64f, .69f, .5f);
_page.Node("TaskCard" + (i + 1)).GetComponent<Image>().color = claimed ? new Color(.89f, .93f, .92f) : Color.white;
}
}
void SetTaskAction(string claim, string go, string done, bool claimed, bool ready, bool current)
{
var claimButton = _page.Button(claim); var goButton = _page.Button(go);
claimButton.gameObject.SetActive(!claimed && ready);
claimButton.interactable = !Blocked && current && ready && !claimed;
goButton.gameObject.SetActive(!claimed && !ready);
goButton.interactable = !Blocked && current && !claimed && !ready;
_page.Node(done).gameObject.SetActive(claimed);
}
void SetTaskProgress(string name, float value)
{
var fill = (RectTransform)_page.Node(name + "Fill");
var track = (RectTransform)_page.Node(name + "Track");
float progress = Mathf.Clamp01(value);
fill.gameObject.SetActive(progress > 0);
fill.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, track.rect.width * progress);
}
void RefreshSignin()
{
var s = _game.State;
_page.Text("SigninSummary", "累计登录 " + s.signinDays + " / 7 天 · 已领取 " + s.signinClaimed.Sum() + " / 7\n漏登不重置 · 每天首次进入计一次");
_page.Text("SigninSummary", "已相伴 " + s.signinDays + " 天 · 收获 " + s.signinClaimed.Sum() + " / 7");
for (int i = 0; i < 7; i++)
{
bool unlocked = i < s.signinDays, claimed = s.signinClaimed[i] == 1;
_page.Text("SigninReward" + i, DailyRewardText(_config.daily.signin[i]));
_page.Text("Signin" + i, claimed ? "已领取" : unlocked ? "领取奖励" : "未解锁");
_page.Text("Signin" + i, "领取");
_page.Button("Signin" + i).gameObject.SetActive(unlocked && !claimed);
_page.Node("Txt_SigninState" + i).gameObject.SetActive(!unlocked || claimed);
_page.Text("SigninState" + i, claimed ? "已领取" : "第 " + (i + 1) + " 天解锁");
_page.Node("SigninCard" + i).GetComponent<Image>().color = claimed
? new Color(.86f, .91f, .90f) : i == 6 ? new Color(1, .94f, .8f) : Color.white;
_page.Button("Signin" + i).interactable = !Blocked && unlocked && !claimed;
}
}
@@ -43,6 +43,7 @@ namespace HotUpdate.UI
void OnEnable()
{
if (_stage != null) _stage.SetActive(true);
_image = GetComponent<RawImage>();
if (_image != null) _image.enabled = _camera != null;
if (_camera != null) _camera.enabled = true;
@@ -52,6 +53,7 @@ namespace HotUpdate.UI
void OnDisable()
{
if (_camera != null) _camera.enabled = false;
if (_stage != null) _stage.SetActive(false);
if (_image != null) _image.enabled = false;
if (_bubble != null) _bubble.gameObject.SetActive(false);
}
@@ -17,12 +17,26 @@ namespace HotUpdate.UI
GameObject _reconnectPanel;
TextMeshProUGUI _reconnectText;
Button _reconnectBtn;
HomeCampPresenter _campPresenter;
public event Action OnReconnectClicked;
public event Action<string> OnHomeActionClicked;
public void TriggerHomeAction(string key)
{
OnHomeActionClicked?.Invoke(key);
}
public void Bind()
{
var campWorld = transform.Find("CampWorld");
if (campWorld != null)
{
_campPresenter = campWorld.GetComponent<HomeCampPresenter>();
if (_campPresenter == null)
_campPresenter = campWorld.gameObject.AddComponent<HomeCampPresenter>();
_campPresenter.SetOwner(this);
}
_status = FindTmp("Txt_Status");
_pid = FindTmp("Txt_Pid");
var panel = transform.Find("Panel_Reconnect");
@@ -39,12 +53,35 @@ namespace HotUpdate.UI
foreach (var button in GetComponentsInChildren<Button>(true))
{
if (button == _reconnectBtn || button.name == "Btn_Explorer") continue;
var key = button.name.StartsWith("Btn_Nav", StringComparison.Ordinal)
? button.name.Substring(7)
: button.name.StartsWith("Btn_", StringComparison.Ordinal) ? button.name.Substring(4) : null;
string key = null;
string bRaw = null;
if (button.name.StartsWith("Btn_Nav", StringComparison.Ordinal))
key = button.name.Substring(7);
else if (button.name.StartsWith("Btn_Building_", StringComparison.Ordinal))
{
bRaw = button.name.Substring(13);
if (bRaw == "MainHouse") key = "Camp";
else if (bRaw == "Fountain") key = "Rewards";
else if (bRaw == "Stall") key = "Daily";
else key = bRaw;
}
else if (button.name.StartsWith("Btn_", StringComparison.Ordinal))
key = button.name.Substring(4);
if (key == null) continue;
string actionKey = key;
string buildingRaw = bRaw;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => OnHomeActionClicked?.Invoke(key));
button.onClick.AddListener(() =>
{
if (!string.IsNullOrEmpty(buildingRaw) && _campPresenter != null && _campPresenter.HasExplorer)
{
_campPresenter.NavigateToBuilding(buildingRaw, actionKey, () => OnHomeActionClicked?.Invoke(actionKey));
}
else
{
OnHomeActionClicked?.Invoke(actionKey);
}
});
}
}
@@ -142,4 +179,436 @@ namespace HotUpdate.UI
return t.GetComponent<TextMeshProUGUI>();
}
}
/// <summary>Displays the authored camp scene behind the existing HUD.</summary>
public sealed class HomeCampPresenter : MonoBehaviour
{
const string ScenePath = "Assets/Bundles/Scenes/Town/HomeCamp/HomeCamp.unity";
const float ReferenceAspect = 1080f / 1920f;
RawImage _image;
Camera _camera;
GameObject _sceneRoot;
Transform _backdrop;
RenderTexture _texture;
YooAsset.SceneHandle _handle;
bool _loading, _destroyed;
GameObject _explorerStage, _explorerButton;
// Building transform references and UI bubble anchors
Transform _mainHouse, _houseLeft, _houseRight, _fountain, _stall;
RectTransform _bubbleMainHouse, _bubbleShop, _bubblePets, _bubbleFountain, _bubbleStall;
// Protagonist Explorer references
Transform _explorer;
HotUpdate.Gameplay.CampExplorerController _explorerController;
MainHudView _owner;
public void SetOwner(MainHudView owner) => _owner = owner;
public bool HasExplorer => _explorerController != null;
struct BuildingEntrance
{
public Vector3 position;
public Vector3 facing;
}
static readonly System.Collections.Generic.Dictionary<string, BuildingEntrance> BuildingEntrances =
new System.Collections.Generic.Dictionary<string, BuildingEntrance>(StringComparer.OrdinalIgnoreCase)
{
{ "MainHouse", new BuildingEntrance { position = new Vector3(0f, 0f, 3.5f), facing = Vector3.forward } },
{ "Shop", new BuildingEntrance { position = new Vector3(-3.8f, 0f, 1.8f), facing = new Vector3(-1f, 0f, 0.3f) } },
{ "HouseLeft", new BuildingEntrance { position = new Vector3(-3.8f, 0f, 1.8f), facing = new Vector3(-1f, 0f, 0.3f) } },
{ "Pets", new BuildingEntrance { position = new Vector3(3.8f, 0f, 1.8f), facing = new Vector3(1f, 0f, 0.3f) } },
{ "HouseRight", new BuildingEntrance { position = new Vector3(3.8f, 0f, 1.8f), facing = new Vector3(1f, 0f, 0.3f) } },
{ "Fountain", new BuildingEntrance { position = new Vector3(0f, 0f, -2.6f), facing = Vector3.forward } },
{ "Stall", new BuildingEntrance { position = new Vector3(-2.8f, 0f, -3.2f), facing = Vector3.left } },
};
string _currentNavBuilding;
public void NavigateToBuilding(string buildingKey, string actionKey, Action onArrive)
{
if (_explorerController == null)
{
onArrive?.Invoke();
return;
}
// 再次点击同一建筑则立即快速进入
if (_currentNavBuilding == buildingKey && _explorerController.IsMoving)
{
_currentNavBuilding = null;
_explorerController.Stop();
onArrive?.Invoke();
return;
}
if (BuildingEntrances.TryGetValue(buildingKey, out var entrance))
{
_currentNavBuilding = buildingKey;
_explorerController.MoveTo(entrance.position, () =>
{
_currentNavBuilding = null;
onArrive?.Invoke();
}, entrance.facing);
}
else
{
onArrive?.Invoke();
}
}
public bool Ready => _camera != null && _texture != null;
public bool Loading => _loading;
void Awake()
{
_image = GetComponent<RawImage>();
if (_image != null) _image.enabled = false;
}
void OnEnable()
{
if (_sceneRoot != null) { _sceneRoot.SetActive(true); Resize(); UpdateBuildingAnchors(); }
else if (!_loading) Load();
}
void OnDisable()
{
if (_sceneRoot != null) _sceneRoot.SetActive(false);
if (_image != null) _image.enabled = false;
}
void Load()
{
_loading = true;
if (YooAsset.YooAssets.IsInitialized && YooAsset.YooAssets.TryGetPackage("Art", out var package) &&
package.InitializeStatus == YooAsset.EOperationStatus.Succeeded && package.IsLocationValid("HomeCamp"))
{
_handle = package.LoadSceneAsync("HomeCamp", UnityEngine.SceneManagement.LoadSceneMode.Additive);
// The shipped October shell stripped SceneHandle.Completed and UnloadSceneAsync.
// Poll inherited HandleBase members on the persistent UI host so hiding the HUD
// during a download does not stop completion/cleanup.
MonoBehaviour host = UiManager.Instance != null ? (MonoBehaviour)UiManager.Instance : this;
host.StartCoroutine(WaitForScene(_handle));
return;
}
#if UNITY_EDITOR
var operation = UnityEditor.SceneManagement.EditorSceneManager.LoadSceneAsyncInPlayMode(
ScenePath, new UnityEngine.SceneManagement.LoadSceneParameters(UnityEngine.SceneManagement.LoadSceneMode.Additive));
if (operation != null)
{
operation.completed += _ => {
var scene = UnityEngine.SceneManagement.SceneManager.GetSceneByPath(ScenePath);
if (_destroyed) { if (scene.IsValid() && scene.isLoaded) UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(scene); return; }
Setup(scene);
};
return;
}
#endif
_loading = false;
Debug.LogWarning("[HomeCamp] Scene unavailable in Art; retaining the illustrated background.");
}
System.Collections.IEnumerator WaitForScene(YooAsset.SceneHandle handle)
{
while (YooAsset.YooAssets.IsInitialized && handle.IsValid && !handle.IsDone) yield return null;
if (YooAsset.YooAssets.IsInitialized && handle.IsValid) Loaded(handle);
}
static void ReleaseScene(YooAsset.SceneHandle handle)
{
if (!YooAsset.YooAssets.IsInitialized || !handle.IsValid) return;
if (handle.Status == YooAsset.EOperationStatus.Succeeded)
{
var scene = handle.SceneObject;
// YooAsset listens to sceneUnloaded and releases its scene handles there.
// Using Unity's retained API also works with the existing AOT shell.
if (scene.IsValid() && scene.isLoaded)
{
UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(scene);
return;
}
}
handle.Release();
}
void Loaded(YooAsset.SceneHandle handle)
{
if (_destroyed) { ReleaseScene(handle); return; }
if (handle.Status != YooAsset.EOperationStatus.Succeeded)
{
Debug.LogWarning("[HomeCamp] Scene load failed: " + handle.Error);
handle.Release(); _handle = null; _loading = false; return;
}
Setup(handle.SceneObject);
}
void Setup(UnityEngine.SceneManagement.Scene scene)
{
_loading = false;
if (scene.IsValid() && scene.isLoaded)
foreach (var root in scene.GetRootGameObjects())
if (root.name == "HomeCamp") { _sceneRoot = root; break; }
if (_sceneRoot != null)
{
_camera = _sceneRoot.GetComponentInChildren<Camera>(true);
_backdrop = _sceneRoot.transform.Find("DistantLakeWaterfallCastle");
_mainHouse = _sceneRoot.transform.Find("MainHouse");
_houseLeft = _sceneRoot.transform.Find("HouseLeft");
_houseRight = _sceneRoot.transform.Find("HouseRight");
_fountain = _sceneRoot.transform.Find("Fountain");
_stall = _sceneRoot.transform.Find("StallRight");
}
if (_camera == null || _image == null)
{
Debug.LogWarning("[HomeCamp] Scene camera missing; retaining the illustrated background.");
if (_sceneRoot != null) _sceneRoot.SetActive(false);
return;
}
_camera.enabled = false;
// The old guide is a separate 2D camera overlay and does not match the new scene's scale or ground.
_explorerStage = transform.parent.Find("ExplorerStage")?.gameObject;
_explorerButton = transform.parent.Find("Btn_Explorer")?.gameObject;
if (_explorerStage != null) _explorerStage.SetActive(false);
if (_explorerButton != null) _explorerButton.SetActive(false);
BindBuildingBubbles();
// Bind Explorer protagonist
if (_sceneRoot != null)
{
_explorer = _sceneRoot.transform.Find("Explorer");
if (_explorer == null)
{
var expPrefab = Resources.Load<GameObject>("Explorer")
#if UNITY_EDITOR
?? UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Bundles/Characters/Human/Explorer/Explorer.prefab")
#endif
;
if (expPrefab != null)
{
var expInst = Instantiate(expPrefab, _sceneRoot.transform);
expInst.name = "Explorer";
expInst.transform.position = new Vector3(0f, 0f, -3.5f);
expInst.transform.rotation = Quaternion.LookRotation(Vector3.forward, Vector3.up);
_explorer = expInst.transform;
}
}
if (_explorer != null)
{
_explorerController = _explorer.GetComponent<HotUpdate.Gameplay.CampExplorerController>();
if (_explorerController == null)
_explorerController = _explorer.gameObject.AddComponent<HotUpdate.Gameplay.CampExplorerController>();
}
}
_sceneRoot.SetActive(isActiveAndEnabled);
if (isActiveAndEnabled)
{
Resize();
UpdateBuildingAnchors();
}
}
void BindBuildingBubbles()
{
var hud = transform.parent;
if (hud == null) return;
var group = hud.Find("Group_CampBuildings");
if (group == null) return;
_bubbleMainHouse = group.Find("Btn_Building_MainHouse") as RectTransform;
_bubbleShop = group.Find("Btn_Building_Shop") as RectTransform;
_bubblePets = group.Find("Btn_Building_Pets") as RectTransform;
_bubbleFountain = group.Find("Btn_Building_Fountain") as RectTransform;
_bubbleStall = group.Find("Btn_Building_Stall") as RectTransform;
}
void Update()
{
if (_camera != null && _image != null && _image.enabled)
{
HandleGroundClick();
}
}
void HandleGroundClick()
{
if (_camera == null || _explorerController == null) return;
bool isClick = false;
Vector2 screenPos = Vector2.zero;
if (Input.GetMouseButtonDown(0))
{
if (UnityEngine.EventSystems.EventSystem.current == null || !UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject())
{
isClick = true;
screenPos = Input.mousePosition;
}
}
else if (Input.touchCount > 0)
{
var touch = Input.GetTouch(0);
if (touch.phase == TouchPhase.Began)
{
if (UnityEngine.EventSystems.EventSystem.current == null || !UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject(touch.fingerId))
{
isClick = true;
screenPos = touch.position;
}
}
}
if (!isClick) return;
Ray ray = _camera.ScreenPointToRay(screenPos);
int layerMask = 1 << 29; // Layer 29 (HomeCamp)
if (Physics.Raycast(ray, out RaycastHit hit, 150f, layerMask))
{
// 优先检查是否点击中 3D 建筑本体
Transform cur = hit.collider.transform;
string matchedBuilding = null;
while (cur != null && cur != _sceneRoot.transform)
{
if (BuildingEntrances.ContainsKey(cur.name))
{
matchedBuilding = cur.name;
break;
}
cur = cur.parent;
}
if (!string.IsNullOrEmpty(matchedBuilding))
{
string actionKey = matchedBuilding;
if (matchedBuilding == "MainHouse") actionKey = "Camp";
else if (matchedBuilding == "Fountain") actionKey = "Rewards";
else if (matchedBuilding == "Stall") actionKey = "Daily";
else if (matchedBuilding == "HouseLeft") actionKey = "Shop";
else if (matchedBuilding == "HouseRight") actionKey = "Pets";
NavigateToBuilding(matchedBuilding, actionKey, () => _owner?.TriggerHomeAction(actionKey));
return;
}
// 点击普通地表或平整可走区域
if (hit.collider.name.IndexOf("Ground", StringComparison.OrdinalIgnoreCase) >= 0 || hit.point.y < 0.8f)
{
_currentNavBuilding = null;
_explorerController.MoveTo(hit.point);
}
}
}
void LateUpdate()
{
if (_camera != null && _image != null && _image.enabled)
{
Resize();
UpdateBuildingAnchors();
}
}
void Resize()
{
if (_camera == null || _image == null || Screen.width <= 0 || Screen.height <= 0) return;
float aspect = (float)Screen.width / Screen.height;
// Preserve the horizontal framing on long phones while showing more vertically.
_camera.fieldOfView = 2f * Mathf.Atan(Mathf.Tan(25f * Mathf.Deg2Rad) * Mathf.Max(1f, ReferenceAspect / aspect)) * Mathf.Rad2Deg;
// Dynamically scale and frame backdrop to avoid gaps on ultra-tall or ultra-wide displays
if (_backdrop != null)
{
const float dist = 75f;
float frustumHalfH = dist * Mathf.Tan(_camera.fieldOfView * Mathf.Deg2Rad * 0.5f);
float frustumHalfW = frustumHalfH * aspect;
float bdW = Mathf.Max(72f, frustumHalfW * 2f * 1.15f);
float bdH = Mathf.Max(56f, frustumHalfH * 1.35f);
float topHeight = frustumHalfH * 1.08f;
_backdrop.localScale = new Vector3(bdW, bdH, 1f);
_backdrop.position = _camera.transform.position + _camera.transform.forward * dist + _camera.transform.up * (topHeight - bdH * 0.5f);
_backdrop.rotation = _camera.transform.rotation;
}
int width = Mathf.Min(Screen.width, 720);
int height = Mathf.Max(1, Mathf.RoundToInt(width / aspect));
if (_texture == null || _texture.width != width || _texture.height != height)
{
_camera.targetTexture = null;
if (_texture != null) { _texture.Release(); Destroy(_texture); }
_texture = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { name = "HomeCampPreview" };
_texture.Create();
_camera.targetTexture = _texture;
_image.texture = _texture;
}
_camera.enabled = true;
_image.enabled = true;
}
void UpdateBuildingAnchors()
{
if (_camera == null) return;
var hud = transform.parent as RectTransform;
if (hud == null) return;
Vector2 canvasSize = hud.rect.size;
if (canvasSize.x <= 0 || canvasSize.y <= 0) return;
UpdateAnchor(_bubbleMainHouse, _mainHouse, new Vector3(0, 6.7f, 7.2f), canvasSize, 0);
UpdateAnchor(_bubbleShop, _houseLeft, new Vector3(-6.8f, 4.0f, 3.6f), canvasSize, 1);
UpdateAnchor(_bubblePets, _houseRight, new Vector3(7.5f, 4.2f, 4.0f), canvasSize, 2);
UpdateAnchor(_bubbleFountain, _fountain, new Vector3(0, 2.5f, 0.8f), canvasSize, 3);
UpdateAnchor(_bubbleStall, _stall, new Vector3(4.5f, 2.6f, 2.8f), canvasSize, 4);
}
void UpdateAnchor(RectTransform bubble, Transform targetTf, Vector3 anchorPos, Vector2 canvasSize, int bobIndex)
{
if (bubble == null) return;
Vector3 worldPos = targetTf != null ? targetTf.position : anchorPos;
worldPos.y = anchorPos.y;
worldPos.x = anchorPos.x;
worldPos.z = anchorPos.z;
Vector3 vp = _camera.WorldToViewportPoint(worldPos);
if (vp.z <= 0 || vp.x < -0.2f || vp.x > 1.2f || vp.y < -0.2f || vp.y > 1.2f)
{
if (bubble.gameObject.activeSelf) bubble.gameObject.SetActive(false);
return;
}
if (!bubble.gameObject.activeSelf) bubble.gameObject.SetActive(true);
float x = (vp.x - 0.5f) * canvasSize.x;
float y = (vp.y - 0.5f) * canvasSize.y;
float bob = Mathf.Sin(Time.time * 2.5f + bobIndex * 1.2f) * 2.5f;
y += bob;
float halfW = canvasSize.x * 0.5f;
float halfH = canvasSize.y * 0.5f;
float bubbleHalfW = bubble.rect.width * 0.5f;
x = Mathf.Clamp(x, -halfW + bubbleHalfW + 15f, halfW - bubbleHalfW - 15f);
y = Mathf.Clamp(y, -halfH + 280f, halfH - 180f);
bubble.anchoredPosition = new Vector2(x, y);
}
void OnDestroy()
{
_destroyed = true;
if (_image != null) _image.texture = null;
if (_camera != null) _camera.targetTexture = null;
if (_texture != null) { _texture.Release(); Destroy(_texture); }
if (_handle != null && !_loading && YooAsset.YooAssets.IsInitialized && _handle.IsValid && _handle.IsDone)
{
ReleaseScene(_handle);
_handle = null;
}
#if UNITY_EDITOR
else if (_handle == null && !_loading)
{
var scene = UnityEngine.SceneManagement.SceneManager.GetSceneByPath(ScenePath);
if (scene.IsValid() && scene.isLoaded) UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(scene);
}
#endif
}
}
}