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