Add prototype gameplay loop with character/battlefield assets and server game APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,12 +6,12 @@ using YooAsset;
|
||||
namespace HotUpdate
|
||||
{
|
||||
/// <summary>
|
||||
/// M3:加载示例角色 MaleExplorer 进场景。
|
||||
/// 加载人物角色 CaoCao 进广场(地址 CaoCao)。
|
||||
/// </summary>
|
||||
public sealed class CharacterSpawner : MonoBehaviour
|
||||
{
|
||||
public const string PrefabAddress = "MaleExplorer";
|
||||
public const string EditorPrefabPath = "Assets/Bundles/Characters/MaleExplorer/MaleExplorer.prefab";
|
||||
public const string PrefabAddress = "CaoCao";
|
||||
public const string EditorPrefabPath = "Assets/Bundles/Characters/Human/CaoCao/CaoCao.prefab";
|
||||
public const string ArtPackageName = "Art";
|
||||
|
||||
BootContext _ctx;
|
||||
@@ -58,7 +58,7 @@ namespace HotUpdate
|
||||
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogError("[CharacterSpawner] MaleExplorer prefab not found");
|
||||
Debug.LogError("[CharacterSpawner] CaoCao prefab not found");
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ namespace HotUpdate
|
||||
Destroy(_instance);
|
||||
|
||||
_instance = Instantiate(prefab);
|
||||
_instance.name = "MaleExplorer_Runtime";
|
||||
_instance.name = "CaoCao_Runtime";
|
||||
var plaza = GameObject.Find("TownSquare_Runtime");
|
||||
var y = GroundSit.Height(Vector3.zero, plaza != null ? plaza.transform : null);
|
||||
_instance.transform.position = new Vector3(0f, y, 0f);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
/// <summary>Input behaviour on authored formation buttons; creates no UI objects.</summary>
|
||||
public sealed class FormationDragHandle : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
public Func<bool> CanBegin;
|
||||
public Action<PointerEventData> Begin, Move, End;
|
||||
bool _dragging;
|
||||
public void OnBeginDrag(PointerEventData data)
|
||||
{
|
||||
if (data.button != PointerEventData.InputButton.Left || CanBegin?.Invoke() != true) return;
|
||||
_dragging = true; data.eligibleForClick = false; Begin?.Invoke(data);
|
||||
}
|
||||
public void OnDrag(PointerEventData data) { if (_dragging) Move?.Invoke(data); }
|
||||
public void OnEndDrag(PointerEventData data) { if (!_dragging) return; _dragging = false; End?.Invoke(data); }
|
||||
void OnDisable() { _dragging = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40458c55607d9284bac0bf0b95156ccc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -14,7 +14,12 @@ namespace HotUpdate.Gameplay
|
||||
GameNet _net;
|
||||
MainHudView _hud;
|
||||
ConfirmDialogView _dialog;
|
||||
PrototypeFlow _prototype;
|
||||
UiPrefabLoader _musicLoader;
|
||||
GameObject _musicInstance;
|
||||
bool _maintenanceLock;
|
||||
string _maintenanceMessage;
|
||||
string _reconnectMessage;
|
||||
|
||||
public void Bind(BootContext ctx, GameNet net)
|
||||
{
|
||||
@@ -24,9 +29,8 @@ namespace HotUpdate.Gameplay
|
||||
|
||||
void Start()
|
||||
{
|
||||
EnsureUi();
|
||||
WireNet();
|
||||
MaybeShowBootNotice();
|
||||
StartCoroutine(EnsureUi());
|
||||
|
||||
var plaza = gameObject.AddComponent<TownSquareSpawner>();
|
||||
plaza.Bind(_ctx);
|
||||
@@ -41,23 +45,30 @@ namespace HotUpdate.Gameplay
|
||||
spawner.Spawn();
|
||||
}
|
||||
|
||||
void EnsureUi()
|
||||
System.Collections.IEnumerator EnsureUi()
|
||||
{
|
||||
var uiRoot = new GameObject("UiRoot");
|
||||
DontDestroyOnLoad(uiRoot);
|
||||
var mgr = uiRoot.AddComponent<UiManager>();
|
||||
mgr.Init(uiRoot.transform, new UiPrefabLoader());
|
||||
mgr.Init(uiRoot.transform, new UiPrefabLoader(_ctx?.Package), _ctx?.UiTheme);
|
||||
|
||||
var hudGo = mgr.Open(MainHudView.PrefabName);
|
||||
GameObject hudGo = null;
|
||||
yield return mgr.OpenAsync(MainHudView.PrefabName, go => hudGo = go);
|
||||
_hud = hudGo != null ? hudGo.GetComponent<MainHudView>() : null;
|
||||
if (_hud == null && hudGo != null)
|
||||
_hud = hudGo.AddComponent<MainHudView>();
|
||||
_hud?.Bind();
|
||||
_hud?.SetStatus("启动中…");
|
||||
_hud?.SetStatus(_maintenanceLock ? "服务器维护中" : FriendlyStatus(_net?.Status));
|
||||
_hud?.SetPid(_net?.Session?.Pid ?? 0);
|
||||
_hud?.ShowReconnect(!_maintenanceLock && _reconnectMessage != null, _reconnectMessage);
|
||||
if (_hud != null)
|
||||
{
|
||||
_hud.OnReconnectClicked += () => _net?.RequestReconnect();
|
||||
_hud.OnHomeActionClicked += ShowHomeFeature;
|
||||
}
|
||||
|
||||
var dlgGo = mgr.Open(ConfirmDialogView.PrefabName);
|
||||
GameObject dlgGo = null;
|
||||
yield return mgr.OpenAsync(ConfirmDialogView.PrefabName, go => dlgGo = go);
|
||||
if (dlgGo != null)
|
||||
{
|
||||
dlgGo.SetActive(false);
|
||||
@@ -65,35 +76,115 @@ namespace HotUpdate.Gameplay
|
||||
if (_dialog == null) _dialog = dlgGo.AddComponent<ConfirmDialogView>();
|
||||
_dialog.Bind();
|
||||
}
|
||||
_prototype = gameObject.AddComponent<PrototypeFlow>();
|
||||
_prototype.Bind(_ctx, mgr, _hud, _dialog, () => _net?.Session, () => _net?.RenewSession());
|
||||
_prototype.SetLocked(_maintenanceLock);
|
||||
StartCoroutine(EnsureMusic());
|
||||
if (_maintenanceLock) _dialog?.Show("维护公告", _maintenanceMessage);
|
||||
else MaybeShowBootNotice();
|
||||
}
|
||||
|
||||
System.Collections.IEnumerator EnsureMusic()
|
||||
{
|
||||
if (_musicInstance != null || _musicLoader != null) yield break;
|
||||
_musicLoader = new UiPrefabLoader(_ctx?.Package, MainMenuMusic.PrefabFolder);
|
||||
GameObject prefab = null;
|
||||
yield return _musicLoader.LoadAsync(MainMenuMusic.PrefabName, loaded => prefab = loaded);
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogWarning("[MainMenuMusic] Music prefab unavailable in the current Art package.");
|
||||
yield break;
|
||||
}
|
||||
_musicInstance = Instantiate(prefab, transform, false);
|
||||
_musicInstance.name = MainMenuMusic.PrefabName;
|
||||
_prototype?.BindMusic(_musicInstance.GetComponent<MainMenuMusic>());
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
StopAllCoroutines();
|
||||
if (_musicInstance != null) Destroy(_musicInstance);
|
||||
_musicLoader?.Dispose();
|
||||
}
|
||||
|
||||
static string FriendlyStatus(string status)
|
||||
{
|
||||
if (string.IsNullOrEmpty(status) || status == "idle") return "启动中…";
|
||||
if (status == "auth…") return "正在登录…";
|
||||
if (status.StartsWith("authed")) return "登录成功,正在连接…";
|
||||
if (status == "ws connected" || status == "ws reconnected") return "已连接";
|
||||
if (status.StartsWith("ws connecting")) return "正在连接游戏服务…";
|
||||
if (status.StartsWith("ws reconnecting")) return "正在恢复连接…";
|
||||
if (status.StartsWith("ws down") || status.StartsWith("ws fail")) return "游戏服务连接已断开";
|
||||
return status;
|
||||
}
|
||||
|
||||
void ShowHomeFeature(string key)
|
||||
{
|
||||
if (_maintenanceLock || _dialog == null) return;
|
||||
if (_prototype != null && _prototype.Handle(key == "Home" ? "Camp" : key)) return;
|
||||
if (key == "Home") { _dialog.Hide(); return; }
|
||||
string title;
|
||||
switch (key)
|
||||
{
|
||||
case "Profile": title = "探险家档案"; break;
|
||||
case "Coin": title = "金币"; break;
|
||||
case "Diamond": title = "钻石"; break;
|
||||
case "Energy": title = "冒险体力"; break;
|
||||
case "Tasks": title = "今日目标"; break;
|
||||
case "Settings": title = "设置"; break;
|
||||
case "Events": title = "小镇活动"; break;
|
||||
case "Daily": title = "每日签到"; break;
|
||||
case "Rank": title = "冒险排行"; break;
|
||||
case "Mail": title = "小镇邮件"; break;
|
||||
case "Rewards": title = "宝箱奖励"; break;
|
||||
case "Bag": title = "背包"; break;
|
||||
case "Adventure": case "Battle": title = "开始冒险"; break;
|
||||
case "Shop": title = "小镇商店"; break;
|
||||
case "Pets": title = "伙伴图鉴"; break;
|
||||
case "Pinball": title = "弹珠乐园"; break;
|
||||
default: return;
|
||||
}
|
||||
_dialog.Show(title, "这片新天地正在建设中,\n期待与你一起开启新的旅程!");
|
||||
}
|
||||
|
||||
void WireNet()
|
||||
{
|
||||
if (_net == null) return;
|
||||
_net.OnStatus += s => _hud?.SetStatus(s);
|
||||
_net.OnStatus += s => _hud?.SetStatus(FriendlyStatus(s));
|
||||
_net.OnLoginFailed += error =>
|
||||
{
|
||||
_reconnectMessage = "登录服务暂不可用,请重试。";
|
||||
_hud?.ShowReconnect(true, _reconnectMessage);
|
||||
};
|
||||
_net.OnAuthed += session => _hud?.SetPid(session != null ? session.Pid : 0);
|
||||
_net.OnWsReady += () =>
|
||||
{
|
||||
if (_maintenanceLock) return;
|
||||
_reconnectMessage = null;
|
||||
_hud?.ShowReconnect(false);
|
||||
_hud?.SetStatus("已连接");
|
||||
};
|
||||
_net.OnWsDisconnected += reason =>
|
||||
{
|
||||
if (_maintenanceLock) return;
|
||||
_hud?.ShowReconnect(true, $"网络已断开({reason}),正在重连…");
|
||||
_reconnectMessage = $"网络已断开({reason}),正在重连…";
|
||||
_hud?.ShowReconnect(true, _reconnectMessage);
|
||||
};
|
||||
_net.OnReconnecting += attempt =>
|
||||
{
|
||||
if (_maintenanceLock) return;
|
||||
_hud?.ShowReconnect(true, $"正在重连…(第 {attempt} 次)");
|
||||
_reconnectMessage = $"正在重连…(第 {attempt} 次)";
|
||||
_hud?.ShowReconnect(true, _reconnectMessage);
|
||||
};
|
||||
_net.OnMaintenance += msg =>
|
||||
{
|
||||
_maintenanceLock = true;
|
||||
_prototype?.SetLocked(true);
|
||||
_maintenanceMessage = string.IsNullOrEmpty(msg) ? "服务器维护中,请稍后再试。" : msg;
|
||||
_hud?.ShowReconnect(false);
|
||||
_hud?.SetStatus("服务器维护中");
|
||||
_dialog?.Show("维护公告", string.IsNullOrEmpty(msg) ? "服务器维护中,请稍后再试。" : msg);
|
||||
_dialog?.Show("维护公告", _maintenanceMessage);
|
||||
};
|
||||
_net.OnWsMessage += TryHandleProtocol;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
/// <summary>Playback for the authored main-menu music prefab, independent of SFX.</summary>
|
||||
public sealed class MainMenuMusic : MonoBehaviour
|
||||
{
|
||||
public const string PrefabName = "MainMenuMusic";
|
||||
public const string PrefabFolder = "Assets/Bundles/Sound/Prefab";
|
||||
[SerializeField] AudioSource _source;
|
||||
bool _inMenu = true, _appPaused, _started, _began, _paused;
|
||||
|
||||
public AudioSource Source => _source;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
PrototypeClientPrefs.AudioPreferencesChanged += ApplyPlayback;
|
||||
ApplyPlayback();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
_started = true;
|
||||
ApplyPlayback();
|
||||
}
|
||||
|
||||
public void SetInMenu(bool inMenu)
|
||||
{
|
||||
_inMenu = inMenu;
|
||||
ApplyPlayback();
|
||||
}
|
||||
|
||||
void ApplyPlayback()
|
||||
{
|
||||
if (!_started || _source == null) return;
|
||||
if (isActiveAndEnabled && _inMenu && !_appPaused && PrototypeClientPrefs.Music)
|
||||
{
|
||||
if (!_began)
|
||||
{
|
||||
_source.Play();
|
||||
_began = true;
|
||||
}
|
||||
else if (_paused) _source.UnPause();
|
||||
_paused = false;
|
||||
}
|
||||
else if (_began && !_paused)
|
||||
{
|
||||
_source.Pause();
|
||||
_paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
void OnApplicationPause(bool paused)
|
||||
{
|
||||
_appPaused = paused;
|
||||
ApplyPlayback();
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
PrototypeClientPrefs.AudioPreferencesChanged -= ApplyPlayback;
|
||||
if (_source != null && _began && !_paused)
|
||||
{
|
||||
_source.Pause();
|
||||
_paused = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8093ba8674c3ed642ad1d2ce72eb5693
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Text;
|
||||
using HotUpdate.Net;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
[Serializable] public sealed class PrototypeCommand
|
||||
{
|
||||
public string requestId, action, activityId, mailId;
|
||||
public string[] mailIds;
|
||||
public int revision, aim, pet, slot, stageId, item, day, chest, choice;
|
||||
}
|
||||
[Serializable] public sealed class RemoteShot
|
||||
{
|
||||
public string id;
|
||||
public long readyAt;
|
||||
public Vector2[] path;
|
||||
public int bin, candy, coins;
|
||||
public PinballResult ToPresentation()
|
||||
{
|
||||
var r = new PinballResult { Bin = bin, Candy = candy, Coins = coins };
|
||||
r.Path.AddRange(path); return r;
|
||||
}
|
||||
}
|
||||
[Serializable] public sealed class BattleSnapshot { public int level, stageId; public int[] stages, formation; public bool legacy; public PrototypeConfig config; }
|
||||
[Serializable] public sealed class RemoteBattle { public string id; public long readyAt; public BattleSnapshot snapshot; }
|
||||
[Serializable] public sealed class CaptureProbability { public int pet; public float baseProbability, probability; }
|
||||
[Serializable] public sealed class DailyStatus { public int day; public long resetAt; }
|
||||
[Serializable] public sealed class RewardsStatus { public int dailyClaimed; }
|
||||
[Serializable] public sealed class CaptureStatus
|
||||
{
|
||||
public bool tutorial, completed, pityReady;
|
||||
public int count, duplicateStreak;
|
||||
public CaptureProbability[] entries;
|
||||
}
|
||||
[Serializable] public sealed class MailClaimEntry { public string mailId, status; }
|
||||
[Serializable] public sealed class BattleHeroStats { public int pet, slot, skills; public float damage, healing, absorbed; }
|
||||
[Serializable] public sealed class PrototypeResult
|
||||
{
|
||||
public string id;
|
||||
public int pet, durability, defeated;
|
||||
public bool duplicate, won, firstClear, tutorial, pity;
|
||||
public int shards;
|
||||
public int item, slot, chargedCoins;
|
||||
public float duration;
|
||||
public int stageId;
|
||||
public StageReward reward;
|
||||
public int chest, choice, remaining, count;
|
||||
public ChestReward chestReward;
|
||||
public string kind;
|
||||
public string mailId;
|
||||
public int claimedCount;
|
||||
public MailClaimEntry[] entries;
|
||||
public BattleHeroStats[] stats;
|
||||
public int[] leaks;
|
||||
}
|
||||
[Serializable] public sealed class PrototypeReply
|
||||
{
|
||||
public bool ok, replayed;
|
||||
public string error, configVersion, action;
|
||||
public long serverTime;
|
||||
public PrototypeSave state;
|
||||
public PrototypeConfig config;
|
||||
public RemoteShot shot;
|
||||
public RemoteBattle battle;
|
||||
public PrototypeResult result;
|
||||
public PrototypeResult lastBattleResult;
|
||||
public PrototypeResult lastCaptureResult;
|
||||
public CaptureStatus capture;
|
||||
public DailyStatus daily;
|
||||
public RewardsStatus rewards;
|
||||
}
|
||||
|
||||
/// <summary>One durable request at a time, scoped to endpoint + authenticated player. Never stores tokens.</summary>
|
||||
public sealed class PrototypeApi
|
||||
{
|
||||
readonly string _base, _key;
|
||||
readonly Func<SessionData> _session;
|
||||
readonly long _pid;
|
||||
readonly Action _reauthenticate;
|
||||
public bool HasPending => !string.IsNullOrEmpty(PlayerPrefs.GetString(_key, ""));
|
||||
public PrototypeApi(string httpBase, long pid, Func<SessionData> session, Action reauthenticate = null)
|
||||
{
|
||||
_base = httpBase.TrimEnd('/'); _pid = pid; _session = session;
|
||||
_reauthenticate = reauthenticate;
|
||||
_key = "AIGame.G1.pending." + Uri.EscapeDataString(_base) + "." + pid;
|
||||
}
|
||||
public PrototypeCommand Command(string action, int revision, int aim = 0, int pet = 0, int slot = 0, string activity = null, int stageId = 0, int item = 0, int day = 0, string mailId = null, string[] mailIds = null, int chest = 0, int choice = 0)
|
||||
=> new PrototypeCommand { requestId = Guid.NewGuid().ToString("N"), action = action, revision = revision, aim = aim, pet = pet, slot = slot, activityId = activity, stageId = stageId, item = item, day = day, mailId = mailId, mailIds = mailIds, chest = chest, choice = choice };
|
||||
public IEnumerator Read(Action<PrototypeReply, string> done) => Request(null, done);
|
||||
public IEnumerator Send(PrototypeCommand command, Action<PrototypeReply, string> done)
|
||||
{
|
||||
if (HasPending) { done(null, "pending_request"); yield break; }
|
||||
string body = JsonUtility.ToJson(command);
|
||||
PlayerPrefs.SetString(_key, body); PlayerPrefs.Save();
|
||||
yield return Request(body, done);
|
||||
}
|
||||
public IEnumerator Replay(Action<PrototypeReply, string> done)
|
||||
{
|
||||
var body = PlayerPrefs.GetString(_key, "");
|
||||
if (string.IsNullOrEmpty(body)) yield return Read(done);
|
||||
else yield return Request(body, done);
|
||||
}
|
||||
static bool ValidMailReceipt(PrototypeReply reply, PrototypeCommand request)
|
||||
{
|
||||
if (reply.action != request.action) return false;
|
||||
if (!reply.ok) return true;
|
||||
var r = reply.result;
|
||||
if (r == null || r.id != request.requestId) return false;
|
||||
if (request.action == "mail.read") return r.mailId == request.mailId;
|
||||
if (r.reward == null) return false;
|
||||
var reward = r.reward;
|
||||
if (reward.coins < 0 || reward.coins > 200000 || reward.candy < 0 || reward.candy > 200000 || reward.balls < 0 || reward.balls > 200000 || reward.tickets < 0 || reward.tickets > 200000) return false;
|
||||
bool hasReward = reward.coins > 0 || reward.candy > 0 || reward.balls > 0 || reward.tickets > 0;
|
||||
if ((r.claimedCount > 0) != hasReward) return false;
|
||||
if (request.action == "mail.claim") return r.mailId == request.mailId && r.claimedCount == 1;
|
||||
if (request.action != "mail.claimAll" || request.mailIds == null || r.entries == null || r.entries.Length != request.mailIds.Length || r.claimedCount < 0 || r.claimedCount > r.entries.Length) return false;
|
||||
int count = 0;
|
||||
for (int i = 0; i < r.entries.Length; i++)
|
||||
{
|
||||
var e = r.entries[i];
|
||||
if (e == null || e.mailId != request.mailIds[i]) return false;
|
||||
if (e.status == "claimed") count++;
|
||||
else if (e.status != "expired" && e.status != "already_claimed" && e.status != "no_attachment" && e.status != "not_found") return false;
|
||||
}
|
||||
return count == r.claimedCount;
|
||||
}
|
||||
static bool ValidChestReward(ChestReward r) => r != null && r.coins >= 0 && r.coins <= 200000 && r.candy >= 0 && r.candy <= 200000 && r.balls >= 0 && r.balls <= 200000 && r.tickets >= 0 && r.tickets <= 200000 && r.shards >= 0 && r.shards <= 200000 && (r.shards == 0 ? r.shardsPet == -1 : r.shardsPet >= 0 && r.shardsPet < 10);
|
||||
// An opened chest must actually grant something; JsonUtility materializes a missing
|
||||
// nested object as an all-zero reward, which would otherwise pass as valid.
|
||||
static bool GrantsSomething(ChestReward r) => r != null && (r.coins > 0 || r.candy > 0 || r.balls > 0 || r.tickets > 0 || r.shards > 0);
|
||||
static bool ValidChestReceipt(PrototypeReply reply, PrototypeCommand request)
|
||||
{
|
||||
if (reply.action != request.action) return false;
|
||||
if (!reply.ok) return true;
|
||||
var r = reply.result;
|
||||
if (r == null || r.id != request.requestId) return false;
|
||||
if (request.action == "chest.claimDaily") return r.chest >= 0 && r.chest < 3 && r.count == 1 && !GrantsSomething(r.chestReward);
|
||||
if (request.action != "chest.open" || r.chest != request.chest || r.remaining < 0 || !ValidChestReward(r.chestReward) || !GrantsSomething(r.chestReward)) return false;
|
||||
if (r.kind == "choice") return r.choice == request.choice;
|
||||
return r.kind == "fixed";
|
||||
}
|
||||
IEnumerator Renew(string body, string previousToken, Action<PrototypeReply, string> done)
|
||||
{
|
||||
_reauthenticate();
|
||||
double deadline = Time.realtimeSinceStartupAsDouble + 18;
|
||||
while (Time.realtimeSinceStartupAsDouble < deadline)
|
||||
{
|
||||
var next = _session();
|
||||
if (next != null && next.Pid == _pid && next.IsValid && next.Token != previousToken)
|
||||
{
|
||||
yield return Request(body, done, true); yield break;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
done(null, "unauthorized");
|
||||
}
|
||||
IEnumerator Request(string body, Action<PrototypeReply, string> done, bool renewed = false)
|
||||
{
|
||||
var session = _session();
|
||||
if (session != null && session.Pid != _pid) { done(null, "unauthorized"); yield break; }
|
||||
if (session == null || !session.IsValid)
|
||||
{
|
||||
if (!renewed && _reauthenticate != null) yield return Renew(body, session?.Token, done);
|
||||
else done(null, "unauthorized");
|
||||
yield break;
|
||||
}
|
||||
using (var req = new UnityWebRequest(_base + (body == null ? "/game/state" : "/game/command"), body == null ? "GET" : "POST"))
|
||||
{
|
||||
req.timeout = 15; req.downloadHandler = new DownloadHandlerBuffer();
|
||||
req.SetRequestHeader("Authorization", "Bearer " + session.Token);
|
||||
if (body != null) { req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)); req.SetRequestHeader("Content-Type", "application/json"); }
|
||||
yield return req.SendWebRequest();
|
||||
if (req.responseCode == 401 && !renewed && _reauthenticate != null)
|
||||
{
|
||||
yield return Renew(body, session.Token, done); yield break;
|
||||
}
|
||||
PrototypeReply reply = null;
|
||||
try { reply = JsonUtility.FromJson<PrototypeReply>(req.downloadHandler.text); }
|
||||
catch (Exception) { /* A partial/malformed response leaves the durable request untouched. */ }
|
||||
if (req.result != UnityWebRequest.Result.Success || reply == null || !PrototypeConfig.Valid(reply.config) || reply.config.pets.Length < 5 || reply.config.stages.Length != reply.state.clearedStages.Length || reply.config.pets.Length != reply.state.stages.Length || reply.config.camp?.decorations?.Length != 3 || reply.config.daily == null || reply.config.shop == null || reply.configVersion != reply.config.version || !PrototypeGame.Valid(reply.state) || reply.state.schema < 6 || reply.state.schema > 9 || (reply.state.schema >= 7 && (reply.config.mail?.initial == null || reply.config.mail.lifetimeSeconds <= 0)) || (reply.state.schema >= 8 && (reply.config.rewards?.chests?.Length != 3 || reply.rewards == null || reply.rewards.dailyClaimed != reply.state.chestDailyClaimed)) || reply.capture?.entries?.Length != reply.config.pets.Length || reply.daily == null || reply.daily.day != reply.state.dailyDay)
|
||||
{
|
||||
string error = reply?.error;
|
||||
done(null, string.IsNullOrEmpty(error) ? "network_unavailable" : error); yield break;
|
||||
}
|
||||
// Both accepted commands and business refusals are durable server results.
|
||||
if (body != null)
|
||||
{
|
||||
var request = JsonUtility.FromJson<PrototypeCommand>(body);
|
||||
if (request.action != null && request.action.StartsWith("mail.", StringComparison.Ordinal) && !ValidMailReceipt(reply, request))
|
||||
{ done(null, "network_unavailable"); yield break; }
|
||||
if (request.action != null && request.action.StartsWith("chest.", StringComparison.Ordinal) && !ValidChestReceipt(reply, request))
|
||||
{ done(null, "network_unavailable"); yield break; }
|
||||
}
|
||||
if (body != null) { PlayerPrefs.DeleteKey(_key); PlayerPrefs.Save(); }
|
||||
done(reply, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac67579534f04e7499d4f92f171e7c2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
/// <summary>Fixed-step defence; old frozen configs retain classic rules. Rendering never authorizes rewards.</summary>
|
||||
public sealed class PrototypeBattle
|
||||
{
|
||||
public sealed class Hero
|
||||
{
|
||||
public int Pet, Slot, Tier, SkillCasts, SkillTargetLane;
|
||||
public float Hp, MaxHp, Attack, Cooldown, HealCooldown, Pulse, TargetZ;
|
||||
public float SkillCooldown, SkillPulse, SkillTargetZ, Shield, ShieldSeconds, DamageDealt, Healing, Absorbed;
|
||||
public int Lane => Slot / 2;
|
||||
public float Z => Slot % 2 == 0 ? 0 : -2;
|
||||
}
|
||||
public sealed class Enemy
|
||||
{
|
||||
public int Index, Type, Lane;
|
||||
public bool Boss;
|
||||
public float Hp, MaxHp, Z = 11, Cooldown, Pulse;
|
||||
public bool Alive => Hp > 0;
|
||||
public float SlowSeconds, SlowFactor = 1;
|
||||
}
|
||||
public const float Step = .05f;
|
||||
public readonly string Id;
|
||||
public readonly List<Hero> Heroes = new List<Hero>();
|
||||
public readonly List<Enemy> Enemies = new List<Enemy>();
|
||||
public float Time { get; private set; }
|
||||
public int Durability { get; private set; } = 10;
|
||||
public int Spawned { get; private set; }
|
||||
public int Defeated { get; private set; }
|
||||
public readonly int[] Leaks = new int[3];
|
||||
public int LaneCount => _config?.battleRules == 2 ? Stage.lanes : 1;
|
||||
public static float LaneX(int lane) => lane == 0 ? 0 : lane == 1 ? -3 : 3;
|
||||
public bool Finished { get; private set; }
|
||||
public bool Won { get; private set; }
|
||||
public int Wave => Mathf.Min(3, (Mathf.Max(1, Spawned) - 1) / 3 + 1);
|
||||
public const int TotalEnemies = 9;
|
||||
readonly PrototypeConfig _config;
|
||||
public StageConfig Stage { get; }
|
||||
public int StageId { get; }
|
||||
public PrototypeConfig Config => _config;
|
||||
float Damage(float attack, int source, int target, float defense) => Stage == null || !Stage.elementsEnabled ? attack : _config.Damage(attack, source, target, defense);
|
||||
public PrototypeBattle(string id, PrototypeGame game, PrototypeConfig config = null, int stageId = 1)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) throw new ArgumentException("Battle id required.");
|
||||
Id = id; _config = config; StageId = stageId; Stage = config?.Stage(stageId);
|
||||
if (Stage != null) Durability = Stage.durability;
|
||||
for (int slot = 0; slot < LaneCount * 2; slot++)
|
||||
{
|
||||
int pet = game.State.formation[slot];
|
||||
if (pet < 0) continue;
|
||||
float growth = config == null ? game.Growth(pet) : config.Growth(game.State, pet);
|
||||
float hp = (config == null ? PrototypeGame.Pets[pet].Hp : config.pets[pet].hp) * growth;
|
||||
float attack = (config == null ? PrototypeGame.Pets[pet].Attack : config.pets[pet].attack) * growth;
|
||||
Heroes.Add(new Hero { Pet = pet, Slot = slot, Tier = game.State.stages[pet], Hp = hp, MaxHp = hp, Attack = attack });
|
||||
}
|
||||
}
|
||||
public void Tick()
|
||||
{
|
||||
if (_config?.battleRules == 2) TickLanes(); else TickClassic();
|
||||
}
|
||||
List<Enemy> Targets(Hero hero, float range)
|
||||
{
|
||||
var result = Enemies.FindAll(e => e.Alive && e.Lane == hero.Lane && e.Z - hero.Z <= range);
|
||||
result.Sort((a, b) => a.Z != b.Z ? a.Z.CompareTo(b.Z) : a.Index.CompareTo(b.Index));
|
||||
return result;
|
||||
}
|
||||
void Hit(Hero hero, Enemy enemy, float amount)
|
||||
{
|
||||
float damage = Damage(amount, _config.pets[hero.Pet].element, Stage.element, _config.enemies[enemy.Type].defense);
|
||||
hero.DamageDealt += Mathf.Min(enemy.Hp, damage); enemy.Hp -= damage;
|
||||
enemy.Pulse = .2f; if (!enemy.Alive) Defeated++;
|
||||
}
|
||||
void Ability(Hero hero)
|
||||
{
|
||||
if (hero.SkillCooldown > 0) return;
|
||||
var a = _config.pets[hero.Pet].ability; float value = a.Value(hero.Tier);
|
||||
hero.SkillTargetLane = hero.Lane; hero.SkillTargetZ = hero.Z;
|
||||
if (a.kind == "shield")
|
||||
{
|
||||
hero.Shield = Mathf.Max(hero.Shield, value); hero.ShieldSeconds = a.Duration(hero.Tier);
|
||||
}
|
||||
else if (a.kind == "heal")
|
||||
{
|
||||
Hero target = null;
|
||||
foreach (var h in Heroes)
|
||||
if (h.Hp > 0 && h.Hp < h.MaxHp && Mathf.Abs(LaneX(h.Lane) - LaneX(hero.Lane)) <= a.Radius(hero.Tier) * 3 && Mathf.Abs(h.Z - hero.Z) <= a.range && (target == null || h.Hp / h.MaxHp < target.Hp / target.MaxHp)) target = h;
|
||||
if (target == null) return;
|
||||
float amount = Mathf.Min(value, target.MaxHp - target.Hp); target.Hp += amount; hero.Healing += amount;
|
||||
hero.SkillTargetLane = target.Lane; hero.SkillTargetZ = target.Z;
|
||||
}
|
||||
else
|
||||
{
|
||||
var targets = Targets(hero, a.range); if (targets.Count == 0) return;
|
||||
hero.SkillTargetZ = targets[0].Z;
|
||||
if (a.kind == "slow")
|
||||
{
|
||||
var target = targets[0]; float factor = target.Boss ? 1 - (1 - value) * a.bossControlScale : value;
|
||||
target.SlowFactor = Mathf.Min(target.SlowFactor, factor); target.SlowSeconds = Mathf.Max(target.SlowSeconds, a.Duration(hero.Tier)); target.Pulse = .2f;
|
||||
}
|
||||
else for (int i = 0; i < Mathf.Min(a.Targets(hero.Tier), targets.Count); i++) Hit(hero, targets[i], hero.Attack * value);
|
||||
}
|
||||
hero.SkillCooldown = a.cooldown; hero.SkillCasts++; hero.SkillPulse = .6f;
|
||||
}
|
||||
void TickLanes()
|
||||
{
|
||||
if (Finished) return;
|
||||
Time += Step;
|
||||
foreach (var e in Enemies)
|
||||
{
|
||||
e.Pulse = Mathf.Max(0, e.Pulse - Step); e.SlowSeconds = Mathf.Max(0, e.SlowSeconds - Step);
|
||||
if (e.SlowSeconds <= 0) e.SlowFactor = 1;
|
||||
}
|
||||
float nextTime = Spawned / 3 * Stage.waveGap + Spawned % 3 * Stage.spawnGap;
|
||||
if (Spawned < TotalEnemies && Time >= nextTime)
|
||||
{
|
||||
int type = Stage.enemyIds[Spawned]; var d = _config.enemies[type];
|
||||
float hp = (d.hp + Spawned * d.hpStep) * Stage.hpScale;
|
||||
Enemies.Add(new Enemy { Index = Spawned, Type = type, Lane = Stage.enemyLanes[Spawned], Boss = d.boss, Hp = hp, MaxHp = hp }); Spawned++;
|
||||
}
|
||||
foreach (var hero in Heroes)
|
||||
{
|
||||
hero.Pulse = Mathf.Max(0, hero.Pulse - Step); hero.SkillPulse = Mathf.Max(0, hero.SkillPulse - Step);
|
||||
hero.ShieldSeconds = Mathf.Max(0, hero.ShieldSeconds - Step); if (hero.ShieldSeconds <= 0) hero.Shield = 0;
|
||||
if (hero.Hp <= 0) continue;
|
||||
hero.Cooldown -= Step; hero.SkillCooldown -= Step; Ability(hero);
|
||||
var targets = Targets(hero, _config.pets[hero.Pet].range);
|
||||
if (targets.Count > 0 && hero.Cooldown <= 0)
|
||||
{
|
||||
Hit(hero, targets[0], hero.Attack); hero.Cooldown = _config.pets[hero.Pet].cooldown; hero.Pulse = .25f; hero.TargetZ = targets[0].Z;
|
||||
}
|
||||
}
|
||||
foreach (var enemy in Enemies)
|
||||
{
|
||||
if (!enemy.Alive) continue;
|
||||
var d = _config.enemies[enemy.Type]; Hero blocker = null;
|
||||
foreach (var hero in Heroes) if (hero.Hp > 0 && hero.Lane == enemy.Lane && (blocker == null || hero.Slot < blocker.Slot)) blocker = hero;
|
||||
float stop = blocker == null ? -4 : blocker.Z + 1;
|
||||
if (enemy.Z > stop) enemy.Z = Mathf.Max(stop, enemy.Z - d.speed * enemy.SlowFactor * Step);
|
||||
else if (blocker != null)
|
||||
{
|
||||
enemy.Cooldown -= Step;
|
||||
if (enemy.Cooldown <= 0)
|
||||
{
|
||||
float damage = Damage(d.attack * Stage.attackScale, Stage.element, _config.pets[blocker.Pet].element, 0);
|
||||
float absorbed = Mathf.Min(blocker.Shield, damage); blocker.Shield -= absorbed; blocker.Absorbed += absorbed;
|
||||
blocker.Hp = Mathf.Max(0, blocker.Hp - (damage - absorbed)); enemy.Cooldown = d.cooldown; enemy.Pulse = .2f;
|
||||
}
|
||||
}
|
||||
else { Durability -= d.leak; Leaks[enemy.Lane]++; enemy.Hp = 0; }
|
||||
}
|
||||
if (Durability <= 0 || Time >= Stage.timeout) { Durability = Mathf.Max(0, Durability); Finished = true; Won = false; }
|
||||
else if (Spawned == TotalEnemies && Enemies.TrueForAll(e => !e.Alive)) { Finished = true; Won = true; }
|
||||
}
|
||||
void TickClassic()
|
||||
{
|
||||
if (Finished) return;
|
||||
Time += Step;
|
||||
float nextTime = Spawned / 3 * (Stage?.waveGap ?? 22) + Spawned % 3 * (Stage?.spawnGap ?? 4);
|
||||
if (Spawned < TotalEnemies && Time >= nextTime)
|
||||
{
|
||||
int type = Stage == null ? (Spawned == 8 ? 4 : 0) : Stage.enemyIds[Spawned];
|
||||
var definition = _config?.enemies[type];
|
||||
float hp = definition == null ? (Spawned == 8 ? 200 : 60 + Spawned * 4) : (definition.hp + Spawned * definition.hpStep) * Stage.hpScale;
|
||||
Enemies.Add(new Enemy { Index = Spawned, Type = type, Boss = definition?.boss ?? (Spawned == 8), Hp = hp, MaxHp = hp }); Spawned++;
|
||||
}
|
||||
foreach (var hero in Heroes)
|
||||
{
|
||||
hero.Pulse = Mathf.Max(0, hero.Pulse - Step);
|
||||
if (hero.Hp <= 0) continue;
|
||||
hero.Cooldown -= Step; hero.HealCooldown -= Step;
|
||||
var definition = _config?.pets[hero.Pet];
|
||||
float healing = definition?.heal ?? (hero.Pet == 2 ? 12 : 0);
|
||||
if (healing > 0 && hero.HealCooldown <= 0)
|
||||
{
|
||||
Hero target = null;
|
||||
foreach (var h in Heroes) if (h.Hp > 0 && h.Hp < h.MaxHp && (target == null || h.Hp / h.MaxHp < target.Hp / target.MaxHp)) target = h;
|
||||
if (target != null) { target.Hp = Mathf.Min(target.MaxHp, target.Hp + healing); hero.HealCooldown = definition?.healCooldown ?? 1.5f; hero.Pulse = .25f; hero.TargetZ = target.Slot == 0 ? 0 : -2; }
|
||||
}
|
||||
Enemy nearest = null; float z = hero.Slot == 0 ? 0 : -2;
|
||||
float range = definition?.range ?? (hero.Pet == 0 ? 2 : 8);
|
||||
foreach (var enemy in Enemies)
|
||||
if (enemy.Alive && enemy.Z - z <= range && (nearest == null || enemy.Z < nearest.Z)) nearest = enemy;
|
||||
if (nearest != null && hero.Cooldown <= 0)
|
||||
{
|
||||
nearest.Hp -= Damage(hero.Attack, definition?.element ?? 0, Stage?.element ?? 0, _config?.enemies[nearest.Type].defense ?? 0); nearest.Pulse = .2f; hero.Cooldown = definition?.cooldown ?? (hero.Pet == 1 ? .9f : 1.2f); hero.Pulse = .25f; hero.TargetZ = nearest.Z;
|
||||
if (!nearest.Alive) Defeated++;
|
||||
}
|
||||
}
|
||||
foreach (var enemy in Enemies)
|
||||
{
|
||||
enemy.Pulse = Mathf.Max(0, enemy.Pulse - Step);
|
||||
if (!enemy.Alive) continue;
|
||||
var definition = _config?.enemies[enemy.Type];
|
||||
Hero blocker = null;
|
||||
foreach (var hero in Heroes) if (hero.Hp > 0 && (blocker == null || hero.Slot < blocker.Slot)) blocker = hero;
|
||||
float stopZ = blocker == null ? -4 : blocker.Slot == 0 ? 1 : -1;
|
||||
if (enemy.Z > stopZ) enemy.Z = Mathf.Max(stopZ, enemy.Z - (definition?.speed ?? .85f) * Step);
|
||||
else if (blocker != null)
|
||||
{
|
||||
enemy.Cooldown -= Step;
|
||||
if (enemy.Cooldown <= 0) { blocker.Hp = Mathf.Max(0, blocker.Hp - Damage((definition?.attack ?? (enemy.Index == 8 ? 14 : 8)) * (Stage?.attackScale ?? 1), Stage?.element ?? 0, _config?.pets[blocker.Pet].element ?? 0, 0)); enemy.Cooldown = definition?.cooldown ?? 1; enemy.Pulse = .2f; }
|
||||
}
|
||||
else
|
||||
{
|
||||
Durability -= definition?.leak ?? (enemy.Index == 8 ? 5 : 1); enemy.Hp = 0;
|
||||
}
|
||||
}
|
||||
if (Durability <= 0 || Time >= (Stage?.timeout ?? 120)) { Durability = Mathf.Max(0, Durability); Finished = true; Won = false; }
|
||||
else if (Spawned == TotalEnemies && Enemies.TrueForAll(e => !e.Alive)) { Finished = true; Won = true; }
|
||||
}
|
||||
public void Abandon() { Finished = true; Won = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1ce8403ac723e944913d191a7e518d2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,60 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
/// <summary>Local audio/display preferences. Account progress stays on the server.</summary>
|
||||
public static class PrototypeClientPrefs
|
||||
{
|
||||
const string Prefix = "AIGame.Settings.";
|
||||
public static event System.Action AudioPreferencesChanged;
|
||||
public static bool Music
|
||||
{
|
||||
get => PlayerPrefs.GetInt(Prefix + "music", 1) == 1;
|
||||
set { PlayerPrefs.SetInt(Prefix + "music", value ? 1 : 0); PlayerPrefs.Save(); Apply(); }
|
||||
}
|
||||
public static bool Sfx
|
||||
{
|
||||
get => PlayerPrefs.GetInt(Prefix + "sfx", 1) == 1;
|
||||
set { PlayerPrefs.SetInt(Prefix + "sfx", value ? 1 : 0); PlayerPrefs.Save(); Apply(); }
|
||||
}
|
||||
/// <summary>0 low, 1 standard, 2 high.</summary>
|
||||
public static int Quality
|
||||
{
|
||||
get => Mathf.Clamp(PlayerPrefs.GetInt(Prefix + "quality", 1), 0, 2);
|
||||
set { PlayerPrefs.SetInt(Prefix + "quality", Mathf.Clamp(value, 0, 2)); PlayerPrefs.Save(); Apply(); }
|
||||
}
|
||||
public static bool Vibrate
|
||||
{
|
||||
get => PlayerPrefs.GetInt(Prefix + "vibrate", 1) == 1;
|
||||
set { PlayerPrefs.SetInt(Prefix + "vibrate", value ? 1 : 0); PlayerPrefs.Save(); }
|
||||
}
|
||||
public static string QualityLabel => Quality == 0 ? "流畅" : Quality == 2 ? "精细" : "标准";
|
||||
public static bool VibrateSupported
|
||||
{
|
||||
get
|
||||
{
|
||||
#if UNITY_IOS || UNITY_ANDROID
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
public static void Apply()
|
||||
{
|
||||
AudioListener.volume = Music || Sfx ? 1f : 0f;
|
||||
AudioPreferencesChanged?.Invoke();
|
||||
var names = QualitySettings.names;
|
||||
if (names == null || names.Length == 0) return;
|
||||
int level = Quality == 0 ? 0 : Quality == 2 ? names.Length - 1 : names.Length / 2;
|
||||
QualitySettings.SetQualityLevel(Mathf.Clamp(level, 0, names.Length - 1), true);
|
||||
}
|
||||
public static void Pulse()
|
||||
{
|
||||
if (!Vibrate || !VibrateSupported) return;
|
||||
#if UNITY_IOS || UNITY_ANDROID
|
||||
Handheld.Vibrate();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad08048f27f60d24e98c3c7c9a478a25
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
[Serializable] public sealed class EconomyConfig
|
||||
{
|
||||
public int initialBalls, ballCap, recoverySeconds, ticketEvery, duplicateShards, levelCap, trainCost, evolveLevel, evolveShards, evolveCoins, goalBalls;
|
||||
public float levelGrowth, evolveGrowth;
|
||||
}
|
||||
[Serializable] public sealed class PinballConfig
|
||||
{
|
||||
public int maxAim, aimStep;
|
||||
public int[] candy, coins;
|
||||
public float gravity, wallBounce, pegBounce, pegRadius, pegSeparation, bumperSpeed;
|
||||
}
|
||||
[Serializable] public sealed class ElementConfig { public int[] cycle; public float advantage, disadvantage; }
|
||||
[Serializable] public sealed class CaptureConfig { public string name; public int tutorialPet, duplicateLimit; public int[] weights; }
|
||||
[Serializable] public sealed class DecorationConfig { public int id, coins; public string name, description; }
|
||||
[Serializable] public sealed class CampConfig { public int restUnlockStage, buildUnlockStage, slots; public DecorationConfig[] decorations; }
|
||||
[Serializable] public sealed class DailyTaskConfig { public int id, target; public string title; public StageReward reward; }
|
||||
[Serializable] public sealed class DailyConfig { public int utcOffsetSeconds; public DailyTaskConfig[] tasks; public StageReward[] signin; }
|
||||
[Serializable] public sealed class GoodsConfig { public int id, coins, dailyLimit, unlockStage; public string name, description; public StageReward reward; }
|
||||
[Serializable] public sealed class ShopConfig { public GoodsConfig[] goods; }
|
||||
[Serializable] public sealed class InitialMailConfig { public string mailId, title, body; public StageReward reward; }
|
||||
[Serializable] public sealed class MailConfig { public int lifetimeSeconds; public InitialMailConfig[] initial; }
|
||||
[Serializable] public sealed class AbilityConfig
|
||||
{
|
||||
public string name, kind, target;
|
||||
public float range, cooldown, duration, t2Duration, t1, t2, bossControlScale;
|
||||
public int radius, t2Radius, targets, t2Targets;
|
||||
public float Value(int tier) => tier == 2 ? t2 : t1;
|
||||
public float Duration(int tier) => tier == 2 ? t2Duration : duration;
|
||||
public int Radius(int tier) => tier == 2 ? t2Radius : radius;
|
||||
public int Targets(int tier) => tier == 2 ? t2Targets : targets;
|
||||
}
|
||||
[Serializable] public sealed class PetConfig
|
||||
{
|
||||
public int id, element; public string name, role, skill;
|
||||
public float hp, attack, range, cooldown, heal, healCooldown;
|
||||
public AbilityConfig ability;
|
||||
}
|
||||
[Serializable] public sealed class EnemyConfig
|
||||
{
|
||||
public int id, leak; public string name; public bool boss;
|
||||
public float hp, hpStep, attack, speed, defense, cooldown;
|
||||
}
|
||||
[Serializable] public sealed class StageReward { public int coins, candy, balls, tickets; }
|
||||
[Serializable] public sealed class ChestReward { public int coins, candy, balls, tickets, shardsPet = -1, shards; }
|
||||
[Serializable] public sealed class ChestChoice { public string name; public ChestReward reward; }
|
||||
[Serializable] public sealed class ChestConfig
|
||||
{
|
||||
public int id; public string name, kind, description;
|
||||
public ChestReward contents; public ChestChoice[] choices;
|
||||
public bool IsChoice => kind == "choice";
|
||||
}
|
||||
[Serializable] public sealed class ChestDropConfig { public int stageId, chest; }
|
||||
[Serializable] public sealed class RewardsConfig { public int dailyChest; public ChestConfig[] chests; public ChestDropConfig[] stageDrops; }
|
||||
[Serializable] public sealed class StageConfig
|
||||
{
|
||||
public int id, recommendedLevel, element, durability;
|
||||
public string code, name, hint, chapter;
|
||||
public bool elementsEnabled;
|
||||
public float hpScale, attackScale, waveGap, spawnGap, timeout;
|
||||
public int[] enemyIds;
|
||||
public int lanes;
|
||||
public int[] enemyLanes;
|
||||
public StageReward reward;
|
||||
}
|
||||
/// <summary>Received from the server; the only authoring source is Config/Gameplay/greenfield.json.</summary>
|
||||
[Serializable] public sealed class PrototypeConfig
|
||||
{
|
||||
public int schema; public string version, chapter;
|
||||
public int battleRules;
|
||||
public EconomyConfig economy;
|
||||
public PinballConfig pinball;
|
||||
public ElementConfig elements;
|
||||
public CaptureConfig capture;
|
||||
public CampConfig camp;
|
||||
public DailyConfig daily;
|
||||
public ShopConfig shop;
|
||||
public MailConfig mail;
|
||||
public RewardsConfig rewards;
|
||||
public PetConfig[] pets;
|
||||
public EnemyConfig[] enemies;
|
||||
public StageConfig[] stages;
|
||||
public StageConfig Stage(int id) => stages[id - 1];
|
||||
public int Lanes(int stageId) => battleRules == 2 ? Stage(stageId).lanes : 1;
|
||||
public string SkillDescription(int pet, int tier)
|
||||
{
|
||||
if (battleRules != 2) return pets[pet].skill;
|
||||
var a = pets[pet].ability; float value = a.Value(tier);
|
||||
string effect = a.kind == "shield" ? "自身护盾 " + value + ",持续" + a.Duration(tier) + "秒(刷新不叠加)"
|
||||
: a.kind == "heal" ? "治疗" + (a.Radius(tier) > 0 ? "本路或相邻路线" : "本路") + "最虚弱伙伴 " + value
|
||||
: a.kind == "slow" ? "本路敌人减速" + Mathf.RoundToInt((1 - value) * 100) + "% · " + a.Duration(tier) + "秒,首领效果×" + a.bossControlScale
|
||||
: "本路" + a.Targets(tier) + "个目标,攻击×" + value;
|
||||
return "T" + tier + " " + a.name + " · " + a.cooldown + "秒冷却\n" + effect;
|
||||
}
|
||||
public static string ElementName(int id) => new[] { "中性", "火", "草", "岩", "电", "水" }[Mathf.Clamp(id, 0, 5)];
|
||||
public static bool Valid(PrototypeConfig c)
|
||||
{
|
||||
if (c == null || c.schema != 1 || string.IsNullOrEmpty(c.version) || c.economy == null || c.pinball == null || c.elements == null || c.pets == null || (c.pets.Length != 3 && c.pets.Length != 5 && c.pets.Length != 10) || c.enemies?.Length != 5 || (c.stages?.Length != 5 && c.stages?.Length != 20)) return false;
|
||||
if (c.pets.Length >= 5)
|
||||
{
|
||||
if (c.capture == null || c.capture.weights?.Length != c.pets.Length || c.capture.duplicateLimit < 1 || c.capture.tutorialPet != 2) return false;
|
||||
foreach (int weight in c.capture.weights) if (weight < 1 || weight > 1000) return false;
|
||||
}
|
||||
if (c.camp != null)
|
||||
{
|
||||
if (c.camp.slots != 3 || c.camp.decorations?.Length != 3 || c.camp.restUnlockStage != 3 || c.camp.buildUnlockStage != 5) return false;
|
||||
for (int i = 0; i < 3; i++) if (c.camp.decorations[i] == null || c.camp.decorations[i].id != i || c.camp.decorations[i].coins <= 0) return false;
|
||||
}
|
||||
if (c.daily != null)
|
||||
{
|
||||
if (c.daily.utcOffsetSeconds != 28800 || c.daily.tasks?.Length != 3 || c.daily.signin?.Length != 7) return false;
|
||||
for (int i = 0; i < 3; i++) if (c.daily.tasks[i] == null || c.daily.tasks[i].id != i || c.daily.tasks[i].target != (i == 0 ? 3 : 1) || !ValidReward(c.daily.tasks[i].reward)) return false;
|
||||
foreach (var reward in c.daily.signin) if (!ValidReward(reward)) return false;
|
||||
}
|
||||
if (c.shop != null)
|
||||
{
|
||||
if (c.shop.goods?.Length != 3) return false;
|
||||
for (int i = 0; i < 3; i++) { var g = c.shop.goods[i]; if (g == null || g.id != i || g.coins < 0 || g.coins > 100000 || g.dailyLimit < 1 || g.dailyLimit > 100 || g.unlockStage < 0 || g.unlockStage > 5 || !ValidReward(g.reward) || g.reward.tickets < 0 || g.reward.tickets > 10000) return false; }
|
||||
}
|
||||
if (c.economy.levelCap != 20 || c.economy.recoverySeconds <= 0 || c.pinball.candy?.Length != 5 || c.pinball.coins?.Length != 5 || c.elements.cycle?.Length != 5) return false;
|
||||
// Unity may materialize an empty nested object for an absent JSON field.
|
||||
// Old frozen battle configs and schema6 servers have no mail section.
|
||||
if (c.mail != null && (c.mail.lifetimeSeconds != 0 || c.mail.initial != null))
|
||||
{
|
||||
if (c.mail.lifetimeSeconds < 1 || c.mail.lifetimeSeconds > 31536000 || c.mail.initial == null || c.mail.initial.Length > 100) return false;
|
||||
var ids = new System.Collections.Generic.HashSet<string>();
|
||||
foreach (var m in c.mail.initial)
|
||||
if (m == null || string.IsNullOrEmpty(m.mailId) || m.mailId.Length > 64 || !ids.Add(m.mailId) || string.IsNullOrEmpty(m.title) || string.IsNullOrEmpty(m.body) || !ValidReward(m.reward) || m.reward.tickets < 0 || m.reward.tickets > 10000) return false;
|
||||
}
|
||||
if (c.rewards != null && (c.rewards.chests != null || c.rewards.stageDrops != null))
|
||||
{
|
||||
if (c.rewards.chests?.Length != 3 || c.rewards.dailyChest < 0 || c.rewards.dailyChest >= 3) return false;
|
||||
var dropStages = new System.Collections.Generic.HashSet<int>();
|
||||
foreach (var drop in c.rewards.stageDrops ?? new ChestDropConfig[0])
|
||||
if (drop == null || drop.stageId < 1 || drop.stageId > c.stages.Length || drop.chest < 0 || drop.chest >= 3 || !dropStages.Add(drop.stageId)) return false;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var chest = c.rewards.chests[i];
|
||||
if (chest == null || chest.id != i || string.IsNullOrEmpty(chest.name) || !ValidChestReward(chest.contents)) return false;
|
||||
if (chest.kind == "fixed") { if (chest.choices != null && chest.choices.Length > 0) return false; }
|
||||
else if (chest.kind == "choice") { if (chest.choices == null || chest.choices.Length < 2 || chest.choices.Length > c.pets.Length) return false; foreach (var choice in chest.choices) if (choice == null || string.IsNullOrEmpty(choice.name) || !ValidChestReward(choice.reward)) return false; }
|
||||
else return false;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < c.pets.Length; i++) if (c.pets[i] == null || c.pets[i].id != i || c.pets[i].hp <= 0 || c.pets[i].cooldown <= 0) return false;
|
||||
for (int i = 0; i < c.enemies.Length; i++) if (c.enemies[i] == null || c.enemies[i].id != i || c.enemies[i].hp <= 0 || c.enemies[i].speed <= 0) return false;
|
||||
for (int i = 0; i < c.stages.Length; i++)
|
||||
{
|
||||
var s = c.stages[i];
|
||||
if (s == null || s.id != i + 1 || s.enemyIds?.Length != 9 || s.reward == null || s.hpScale <= 0 || s.timeout <= 0 || string.IsNullOrEmpty(s.chapter)) return false;
|
||||
foreach (int id in s.enemyIds) if (id < 0 || id >= c.enemies.Length) return false;
|
||||
}
|
||||
if (c.battleRules != 0 && c.battleRules != 2) return false;
|
||||
if (c.battleRules == 2)
|
||||
{
|
||||
foreach (var s in c.stages)
|
||||
{
|
||||
if (s.lanes < 1 || s.lanes > 3 || s.enemyLanes?.Length != 9) return false;
|
||||
foreach (int lane in s.enemyLanes) if (lane < 0 || lane >= s.lanes) return false;
|
||||
}
|
||||
foreach (var p in c.pets)
|
||||
{
|
||||
var a = p.ability;
|
||||
if (a == null || string.IsNullOrEmpty(a.name) || a.cooldown < .05f || a.cooldown > 120 || a.range < 0 || a.range > 12 || a.t1 <= 0 || a.t1 > 10000 || a.t2 <= 0 || a.t2 > 10000 || a.duration < 0 || a.duration > 30 || a.t2Duration < 0 || a.t2Duration > 30 || a.radius < 0 || a.radius > 2 || a.t2Radius < 0 || a.t2Radius > 2 || a.targets < 1 || a.targets > 3 || a.t2Targets < 1 || a.t2Targets > 3) return false;
|
||||
if (a.kind != "shield" && a.kind != "heal" && a.kind != "burst" && a.kind != "chain" && a.kind != "slow") return false;
|
||||
if (a.target != (a.kind == "shield" ? "self" : a.kind == "heal" ? "ally" : "enemy")) return false;
|
||||
if (a.kind == "slow" && (a.t1 > 1 || a.t2 > 1)) return false;
|
||||
if (a.kind == "slow" && (a.bossControlScale < 0 || a.bossControlScale > 1)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public float Growth(PrototypeSave save, int pet) => (1 + economy.levelGrowth * (save.level - 1)) * (save.stages[pet] == 2 ? economy.evolveGrowth : 1);
|
||||
static bool ValidReward(StageReward r) => r != null && r.coins >= 0 && r.coins <= 10000 && r.candy >= 0 && r.candy <= 10000 && r.balls >= 0 && r.balls <= 10000;
|
||||
static bool ValidChestReward(ChestReward r) => r != null && r.coins >= 0 && r.coins <= 10000 && r.candy >= 0 && r.candy <= 10000 && r.balls >= 0 && r.balls <= 10000 && r.tickets >= 0 && r.tickets <= 10000 && r.shards >= 0 && r.shards <= 10000 && (r.shards == 0 ? r.shardsPet == -1 : r.shardsPet >= 0);
|
||||
public float Damage(float attack, int source, int target, float defense)
|
||||
{
|
||||
float multiplier = 1;
|
||||
if (source != 0 && target != 0)
|
||||
for (int i = 0; i < elements.cycle.Length; i++)
|
||||
{
|
||||
int next = elements.cycle[(i + 1) % elements.cycle.Length];
|
||||
if (elements.cycle[i] == source && next == target) multiplier = elements.advantage;
|
||||
if (elements.cycle[i] == target && next == source) multiplier = elements.disadvantage;
|
||||
}
|
||||
return Mathf.Max(1, Mathf.Floor(attack * multiplier - defense));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 108f0f37d6971a841baba5010f8aadfe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f999b1a65b5e13f448a28660b0d2456e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,316 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.Gameplay
|
||||
{
|
||||
[Serializable] public sealed class PrototypeMail
|
||||
{
|
||||
public string mailId, title, body;
|
||||
public long sentAt, expiresAt;
|
||||
public bool read, claimed, expired;
|
||||
public StageReward reward;
|
||||
public bool HasAttachment => reward != null && (reward.coins > 0 || reward.candy > 0 || reward.balls > 0 || reward.tickets > 0);
|
||||
public bool CanClaim(long now) => !claimed && !expired && now < expiresAt && HasAttachment;
|
||||
}
|
||||
[Serializable]
|
||||
public sealed class PrototypeSave
|
||||
{
|
||||
public int schema = 1, revision, coins, candy, balls = 20, tickets, level = 1, shots;
|
||||
public int[] stages = { 1, 1, 0 };
|
||||
public int[] shards = new int[3];
|
||||
public int[] formation = { 0, 1, -1, -1, -1, -1 };
|
||||
public bool cleared, goalClaimed;
|
||||
public string pendingShot, lastShot, pendingBattle, lastBattle;
|
||||
public int pendingAim, lastCandy, lastCoins;
|
||||
public long ballRecoveryUnix;
|
||||
public bool captureTutorialDone;
|
||||
public int captureCount, duplicateStreak;
|
||||
public int[] campLayout, decorations;
|
||||
public int restPet, campInteractions;
|
||||
public int dailyDay, signinDays;
|
||||
public int[] dailyProgress, dailyClaimed, signinClaimed;
|
||||
public int[] shopBought;
|
||||
public int[] chests;
|
||||
public int chestDailyClaimed;
|
||||
public PrototypeMail[] mails;
|
||||
public int selectedStage = 1;
|
||||
public int[] clearedStages = new int[5];
|
||||
public int UnlockedStage
|
||||
{
|
||||
get { int id = 1; while (id < clearedStages.Length && clearedStages[id - 1] == 1) id++; return id; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrototypePet
|
||||
{
|
||||
public readonly string Name, Element, Role;
|
||||
public readonly int Hp, Attack;
|
||||
public PrototypePet(string name, string element, string role, int hp, int attack)
|
||||
{ Name = name; Element = element; Role = role; Hp = hp; Attack = attack; }
|
||||
}
|
||||
|
||||
public interface IPrototypeStore
|
||||
{
|
||||
PrototypeSave Read();
|
||||
void Write(PrototypeSave state);
|
||||
}
|
||||
|
||||
/// <summary>G0 local prototype only. Separate per-player namespace; never writes production server currency.</summary>
|
||||
public sealed class PrototypeLocalStore : IPrototypeStore
|
||||
{
|
||||
[Serializable] sealed class Envelope { public string data, checksum; public int revision; }
|
||||
readonly string _key;
|
||||
public PrototypeLocalStore(string profile) { _key = "AIGame.G0.v1." + profile; }
|
||||
static string Checksum(string data)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
foreach (char c in data) { hash ^= c; hash *= 16777619; }
|
||||
return hash.ToString("X8");
|
||||
}
|
||||
public PrototypeSave Read()
|
||||
{
|
||||
PrototypeSave latest = null;
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var blob = PlayerPrefs.GetString(_key + "." + i, "");
|
||||
if (string.IsNullOrEmpty(blob)) continue;
|
||||
var e = JsonUtility.FromJson<Envelope>(blob);
|
||||
if (e == null || e.data == null || Checksum(e.data) != e.checksum) continue;
|
||||
var save = JsonUtility.FromJson<PrototypeSave>(e.data);
|
||||
if (save == null || save.revision != e.revision || !PrototypeGame.Valid(save)) continue;
|
||||
if (latest == null || save.revision > latest.revision) latest = save;
|
||||
}
|
||||
catch (Exception) { /* A corrupt slot must not mask its valid alternate. */ }
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
public void Write(PrototypeSave state)
|
||||
{
|
||||
var data = JsonUtility.ToJson(state);
|
||||
var envelope = new Envelope { data = data, checksum = Checksum(data), revision = state.revision };
|
||||
PlayerPrefs.SetString(_key + "." + (state.revision % 2), JsonUtility.ToJson(envelope));
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrototypeGame
|
||||
{
|
||||
public static readonly PrototypePet[] Pets = {
|
||||
new PrototypePet("岩团团", "岩", "守护", 210, 12),
|
||||
new PrototypePet("火喵", "火", "输出", 95, 24),
|
||||
new PrototypePet("芽芽", "草", "支援", 110, 6)
|
||||
};
|
||||
public PrototypeSave State { get; private set; }
|
||||
readonly IPrototypeStore _store;
|
||||
readonly Func<long> _clock;
|
||||
// Live G1 sessions are read-only views of the server; local mutation APIs remain for G0 tests.
|
||||
public PrototypeGame(PrototypeSave authoritative)
|
||||
{
|
||||
if (!Valid(authoritative)) throw new InvalidOperationException("服务端存档无效。");
|
||||
State = authoritative;
|
||||
_clock = () => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
public PrototypeGame(IPrototypeStore store, Func<long> clock = null)
|
||||
{
|
||||
_store = store;
|
||||
_clock = clock ?? (() => DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
State = store.Read() ?? new PrototypeSave();
|
||||
if (!Valid(State)) throw new InvalidOperationException("原型存档版本或内容无效。");
|
||||
if (State.ballRecoveryUnix <= 0) Commit(s => s.ballRecoveryUnix = Math.Max(1, _clock()));
|
||||
}
|
||||
public static bool Valid(PrototypeSave s)
|
||||
{
|
||||
if (s == null || s.schema < 1 || s.schema > 9 || s.level < 1 || s.level > 20 || s.balls < 0 || s.candy < 0 || s.coins < 0 || s.tickets < 0 || s.shots < 0 || s.captureCount < 0 || s.duplicateStreak < 0) return false;
|
||||
int stageCount = s.schema >= 9 ? 20 : 5;
|
||||
if (s.schema >= 2)
|
||||
{
|
||||
if (s.clearedStages == null || s.clearedStages.Length != stageCount || s.selectedStage < 1 || s.selectedStage > stageCount) return false;
|
||||
foreach (int cleared in s.clearedStages) if (cleared != 0 && cleared != 1) return false;
|
||||
if (s.selectedStage > s.UnlockedStage) return false;
|
||||
}
|
||||
int count = s.schema >= 9 ? 10 : s.schema >= 3 ? 5 : 3;
|
||||
if (s.stages == null || s.shards == null || s.formation == null || s.stages.Length != count || s.shards.Length != count || s.formation.Length != 6) return false;
|
||||
var used = new HashSet<int>();
|
||||
for (int i = 0; i < count; i++) if (s.stages[i] < 0 || s.stages[i] > 2 || s.shards[i] < 0) return false;
|
||||
foreach (int id in s.formation) if (id < -1 || id >= count || id >= 0 && (s.stages[id] == 0 || !used.Add(id))) return false;
|
||||
if (s.schema >= 4)
|
||||
{
|
||||
if (s.campLayout?.Length != 3 || s.decorations?.Length != 3 || s.restPet < 0 || s.restPet >= count || s.stages[s.restPet] == 0 || s.campInteractions < 0) return false;
|
||||
used.Clear();
|
||||
foreach (int owned in s.decorations) if (owned != 0 && owned != 1) return false;
|
||||
foreach (int id in s.campLayout) if (id < -1 || id >= 3 || id >= 0 && (s.decorations[id] == 0 || !used.Add(id))) return false;
|
||||
}
|
||||
if (s.schema >= 5)
|
||||
{
|
||||
if (s.dailyDay < 0 || s.signinDays < 1 || s.signinDays > 7 || s.dailyProgress?.Length != 3 || s.dailyClaimed?.Length != 3 || s.signinClaimed?.Length != 7) return false;
|
||||
for (int i = 0; i < 3; i++) if (s.dailyProgress[i] < 0 || s.dailyProgress[i] > (i == 0 ? 3 : 1) || s.dailyClaimed[i] < 0 || s.dailyClaimed[i] > 1) return false;
|
||||
for (int i = 0; i < 7; i++) if (s.signinClaimed[i] < 0 || s.signinClaimed[i] > 1 || i >= s.signinDays && s.signinClaimed[i] == 1) return false;
|
||||
}
|
||||
if (s.schema >= 6) { if (s.shopBought?.Length != 3) return false; foreach (int n in s.shopBought) if (n < 0 || n > 100) return false; }
|
||||
if (s.schema >= 7)
|
||||
{
|
||||
if (s.mails == null || s.mails.Length > 100) return false;
|
||||
var ids = new HashSet<string>();
|
||||
foreach (var m in s.mails)
|
||||
{
|
||||
if (m == null || string.IsNullOrEmpty(m.mailId) || m.mailId.Length > 64 || !ids.Add(m.mailId) || string.IsNullOrEmpty(m.title) || string.IsNullOrEmpty(m.body) || m.sentAt <= 0 || m.expiresAt <= m.sentAt || m.reward == null) return false;
|
||||
var r = m.reward;
|
||||
if (r.coins < 0 || r.coins > 10000 || r.candy < 0 || r.candy > 10000 || r.balls < 0 || r.balls > 10000 || r.tickets < 0 || r.tickets > 10000) return false;
|
||||
}
|
||||
}
|
||||
if (s.schema >= 8)
|
||||
{
|
||||
if (s.chests?.Length != 3 || s.chestDailyClaimed < 0 || s.chestDailyClaimed > 1) return false;
|
||||
foreach (int n in s.chests) if (n < 0 || n > 100000) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void Commit(Action<PrototypeSave> change)
|
||||
{
|
||||
if (_store == null) throw new InvalidOperationException("联网存档必须通过服务端操作更新。");
|
||||
var next = JsonUtility.FromJson<PrototypeSave>(JsonUtility.ToJson(State));
|
||||
change(next); next.revision++;
|
||||
if (!Valid(next)) throw new InvalidOperationException("操作会产生无效存档。");
|
||||
_store.Write(next); // Publish in-memory state only after storage accepts the complete snapshot.
|
||||
State = next;
|
||||
}
|
||||
public bool BeginShot(string id, int aim, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
if (string.IsNullOrEmpty(id) || aim < -35 || aim > 35) { reason = "发射方向无效。"; return false; }
|
||||
RefreshBalls();
|
||||
if (!string.IsNullOrEmpty(State.pendingShot)) { reason = "上一枚弹珠尚未结算。"; return false; }
|
||||
if (State.lastShot == id) { reason = "这次发射已经完成。"; return false; }
|
||||
if (State.balls <= 0) { reason = "弹珠不足,每10分钟恢复一枚;也可挑战教学关或领取教学目标。"; return false; }
|
||||
bool leavingCap = State.balls == 30;
|
||||
Commit(s => { s.balls--; if (leavingCap) s.ballRecoveryUnix = _clock(); s.pendingShot = id; s.pendingAim = aim; });
|
||||
return true;
|
||||
}
|
||||
public bool RefreshBalls()
|
||||
{
|
||||
if (State.balls >= 30) return false;
|
||||
long now = _clock(), elapsed = now - State.ballRecoveryUnix;
|
||||
if (elapsed < 600) return false;
|
||||
int earned = (int)Math.Min(elapsed / 600, 30 - State.balls);
|
||||
Commit(s => { s.balls += earned; s.ballRecoveryUnix = s.balls >= 30 ? now : s.ballRecoveryUnix + earned * 600; });
|
||||
return true;
|
||||
}
|
||||
public bool CompleteShot(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || State.pendingShot != id) return false;
|
||||
var result = PrototypePinball.Simulate(State.pendingAim);
|
||||
Commit(s => {
|
||||
s.coins += result.Coins; s.candy += result.Candy; s.shots++;
|
||||
if (s.shots == 1 || s.shots % 10 == 0) s.tickets++;
|
||||
s.lastCandy = result.Candy; s.lastCoins = result.Coins;
|
||||
s.lastShot = id; s.pendingShot = null;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
public int Capture(out string reason)
|
||||
{
|
||||
reason = null;
|
||||
if (State.tickets < 1) { reason = "捕捉券不足:首次发射赠送一张,此后每十次发射获得一张。"; return -1; }
|
||||
int pet = Array.FindIndex(State.stages, p => p == 0);
|
||||
if (pet < 0) pet = State.shots % Pets.Length;
|
||||
Commit(s => { s.tickets--; if (s.stages[pet] == 0) s.stages[pet] = 1; else s.shards[pet] += 20; });
|
||||
return pet;
|
||||
}
|
||||
public bool Train(out string reason)
|
||||
{
|
||||
reason = null;
|
||||
if (State.level >= 20) { reason = "已达到原型等级上限。"; return false; }
|
||||
int cost = 30 * State.level;
|
||||
if (State.candy < cost) { reason = "糖果不足,升级需要 " + cost + " 糖果。"; return false; }
|
||||
Commit(s => { s.candy -= cost; s.level++; }); return true;
|
||||
}
|
||||
public bool Evolve(int pet, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
if (pet < 0 || pet >= 3 || State.stages[pet] != 1 || State.level < 5 || State.shards[pet] < 40 || State.coins < 500)
|
||||
{ reason = "进化需要 T1 伙伴、训练 Lv.5、对应碎片 40 和金币 500。"; return false; }
|
||||
Commit(s => { s.shards[pet] -= 40; s.coins -= 500; s.stages[pet] = 2; }); return true;
|
||||
}
|
||||
public bool Deploy(int pet, int slot, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
if (pet < 0 || pet > 2 || State.stages[pet] == 0 || slot < 0 || slot > 1)
|
||||
{ reason = "教学关仅开放中央路线的前后两个站位。"; return false; }
|
||||
Commit(s => {
|
||||
int previous = Array.IndexOf(s.formation, pet), occupant = s.formation[slot];
|
||||
if (previous >= 0) s.formation[previous] = occupant;
|
||||
s.formation[slot] = pet;
|
||||
}); return true;
|
||||
}
|
||||
public void AutoDeploy() { Commit(s => { s.formation = new[] { 0, 1, -1, -1, -1, -1 }; }); }
|
||||
public bool SettleBattle(PrototypeBattle battle)
|
||||
{
|
||||
if (battle == null || !battle.Finished || State.lastBattle == battle.Id) return false;
|
||||
Commit(s => {
|
||||
s.lastBattle = battle.Id;
|
||||
if (battle.Won && !s.cleared) { s.cleared = true; s.coins += 80; s.candy += 40; s.balls += 3; }
|
||||
}); return true;
|
||||
}
|
||||
public bool ClaimGoal(out string reason)
|
||||
{
|
||||
reason = null;
|
||||
if (!State.cleared || State.goalClaimed) { reason = State.goalClaimed ? "教学目标已领取。" : "先完成教学关卡 1-1。"; return false; }
|
||||
Commit(s => { s.goalClaimed = true; s.balls += 5; }); return true;
|
||||
}
|
||||
public float Growth(int pet) => (1 + .05f * (State.level - 1)) * (State.stages[pet] == 2 ? 1.25f : 1);
|
||||
}
|
||||
|
||||
public sealed class PinballResult
|
||||
{
|
||||
public readonly List<Vector2> Path = new List<Vector2>();
|
||||
public int Candy, Coins, Bin;
|
||||
}
|
||||
|
||||
/// <summary>Fixed-step, input-dependent board; no client-side random reward substituted for the trajectory.</summary>
|
||||
public static class PrototypePinball
|
||||
{
|
||||
public const float Step = 1f / 60;
|
||||
public static readonly int[] Candy = { 10, 20, 40, 20, 10 }, Coins = { 80, 40, 20, 40, 80 };
|
||||
public static readonly Vector2[] Pegs = MakePegs();
|
||||
static Vector2[] MakePegs()
|
||||
{
|
||||
var pegs = new List<Vector2>();
|
||||
for (int row = 0; row < 4; row++) for (int col = 0; col < 5; col++)
|
||||
pegs.Add(new Vector2(.12f + col * .19f + (row % 2 == 0 ? 0 : .025f), .72f - row * .16f));
|
||||
return pegs.ToArray();
|
||||
}
|
||||
public static PinballResult Simulate(int aim)
|
||||
{
|
||||
var result = new PinballResult();
|
||||
var p = new Vector2(.5f, .95f);
|
||||
var v = new Vector2(Mathf.Sin(Mathf.Clamp(aim, -35, 35) * Mathf.Deg2Rad) * .95f, -.35f);
|
||||
result.Path.Add(p);
|
||||
for (int tick = 0; tick < 600; tick++)
|
||||
{
|
||||
v.y -= 1.15f * Step; p += v * Step;
|
||||
if (p.x < .025f) { p.x = .025f; v.x = Mathf.Abs(v.x) * .85f; }
|
||||
if (p.x > .975f) { p.x = .975f; v.x = -Mathf.Abs(v.x) * .85f; }
|
||||
foreach (var peg in Pegs)
|
||||
{
|
||||
var d = p - peg;
|
||||
if (d.sqrMagnitude >= .055f * .055f) continue;
|
||||
var normal = d.sqrMagnitude < .000001f ? new Vector2(.4f, 1).normalized : d.normalized;
|
||||
p = peg + normal * .056f;
|
||||
if (Vector2.Dot(v, normal) < 0) v = Vector2.Reflect(v, normal) * .78f;
|
||||
// Active round bumpers deflect a near-vertical hit instead of trapping a ball on top.
|
||||
// The tie-break follows aim, so the board remains deterministic and replayable.
|
||||
if (normal.y > .8f && Mathf.Abs(v.x) < .06f) v.x = aim < 0 ? -.18f : .18f;
|
||||
}
|
||||
if (p.y > .98f) { p.y = .98f; v.y = -Mathf.Abs(v.y); }
|
||||
result.Path.Add(p);
|
||||
if (p.y <= .035f) break;
|
||||
}
|
||||
result.Bin = Mathf.Clamp((int)(p.x * 5), 0, 4);
|
||||
result.Candy = Candy[result.Bin]; result.Coins = Coins[result.Bin];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 77edf93d0ea693a4680b041913d6a75e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,35 +13,44 @@ namespace HotUpdate
|
||||
public static float Height(Vector3 xz, Transform searchRoot = null, float fallback = 0f)
|
||||
{
|
||||
Physics.SyncTransforms();
|
||||
var cols = Collect(searchRoot);
|
||||
var offsets = new[]
|
||||
{
|
||||
Vector3.zero,
|
||||
new Vector3(0.35f, 0f, 0.35f),
|
||||
new Vector3(-0.35f, 0f, 0.35f),
|
||||
new Vector3(0.35f, 0f, -0.35f),
|
||||
};
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
if (TryHit(cols, xz + offset, groundOnly: true, out var y))
|
||||
return y;
|
||||
}
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
if (TryHit(cols, xz + offset, groundOnly: false, out var y))
|
||||
return y;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static bool TryHit(MeshCollider[] cols, Vector3 xz, bool groundOnly, out float y)
|
||||
{
|
||||
var ray = new Ray(new Vector3(xz.x, RayStartY, xz.z), Vector3.down);
|
||||
var bestDist = float.MaxValue;
|
||||
var y = fallback;
|
||||
y = 0f;
|
||||
var hitAny = false;
|
||||
|
||||
foreach (var col in Collect(searchRoot))
|
||||
foreach (var col in cols)
|
||||
{
|
||||
if (col == null || !col.enabled) continue;
|
||||
if (!IsGroundCollider(col)) continue;
|
||||
if (groundOnly && !IsGroundCollider(col)) continue;
|
||||
if (!col.Raycast(ray, out var hit, RayLength)) continue;
|
||||
if (hit.distance >= bestDist) continue;
|
||||
bestDist = hit.distance;
|
||||
y = hit.point.y;
|
||||
hitAny = true;
|
||||
}
|
||||
|
||||
if (hitAny) return y;
|
||||
|
||||
foreach (var col in Collect(searchRoot))
|
||||
{
|
||||
if (col == null || !col.enabled) continue;
|
||||
if (!col.Raycast(ray, out var hit, RayLength)) continue;
|
||||
if (hit.distance >= bestDist) continue;
|
||||
bestDist = hit.distance;
|
||||
y = hit.point.y;
|
||||
hitAny = true;
|
||||
}
|
||||
|
||||
return hitAny ? y : fallback;
|
||||
return hitAny;
|
||||
}
|
||||
|
||||
static MeshCollider[] Collect(Transform searchRoot)
|
||||
|
||||
@@ -24,11 +24,22 @@ namespace HotUpdate.Net
|
||||
public event Action<string> OnWsDisconnected;
|
||||
public event Action<int> OnReconnecting;
|
||||
public event Action<string> OnMaintenance;
|
||||
public event Action<string> OnLoginFailed;
|
||||
|
||||
BootContext _boot;
|
||||
bool _started;
|
||||
bool _maintenance;
|
||||
bool _everConnected;
|
||||
bool _authenticating;
|
||||
|
||||
/// <summary>Renew the same guest identity after an authenticated game API returns 401.</summary>
|
||||
public void RenewSession()
|
||||
{
|
||||
if (_maintenance || _authenticating) return;
|
||||
Socket?.Dispose(); Socket = null; Session = null;
|
||||
_started = false;
|
||||
StartGuestSession();
|
||||
}
|
||||
|
||||
public void Bind(BootContext boot)
|
||||
{
|
||||
@@ -61,7 +72,8 @@ namespace HotUpdate.Net
|
||||
/// <summary>UI「立即重连」:重置退避并马上再连。</summary>
|
||||
public void RequestReconnect()
|
||||
{
|
||||
if (_maintenance || Session == null || !Session.IsValid) return;
|
||||
if (_maintenance) return;
|
||||
if (Session == null || !Session.IsValid) { StartGuestSession(); return; }
|
||||
SetStatus("手动重连…");
|
||||
if (Socket != null)
|
||||
{
|
||||
@@ -87,6 +99,7 @@ namespace HotUpdate.Net
|
||||
|
||||
IEnumerator RunGuestFlow()
|
||||
{
|
||||
_authenticating = true;
|
||||
SetStatus("auth…");
|
||||
var deviceId = DeviceId.GetOrCreate();
|
||||
var username = $"guest_{deviceId}";
|
||||
@@ -95,11 +108,14 @@ namespace HotUpdate.Net
|
||||
AuthResponse auth = null;
|
||||
string err = null;
|
||||
yield return AuthApi.EnsureAccount(HttpBase, username, password, (r, e) => { auth = r; err = e; });
|
||||
_authenticating = false;
|
||||
|
||||
if (auth == null || string.IsNullOrEmpty(auth.token))
|
||||
{
|
||||
SetStatus($"auth fail: {err}");
|
||||
_started = false;
|
||||
SetStatus("登录服务连接失败,请重试");
|
||||
Debug.LogError($"[GameNet] auth failed: {err}");
|
||||
OnLoginFailed?.Invoke(err);
|
||||
yield break;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,32 @@ namespace HotUpdate.UI
|
||||
Button _close;
|
||||
Action _onOk;
|
||||
Action _onCancel;
|
||||
RectTransform _okRect;
|
||||
Vector2 _okAnchorMin, _okAnchorMax, _okPivot, _okPosition;
|
||||
ScrollRect _messageScroll;
|
||||
|
||||
public struct ViewState
|
||||
{
|
||||
public string Title, Message;
|
||||
public bool Visible, ShowCancel;
|
||||
public Action OnOk, OnCancel;
|
||||
}
|
||||
|
||||
public ViewState CaptureState() => new ViewState
|
||||
{
|
||||
Title = _title != null ? _title.text : string.Empty,
|
||||
Message = _message != null ? _message.text : string.Empty,
|
||||
Visible = gameObject.activeSelf,
|
||||
ShowCancel = _cancel != null && _cancel.gameObject.activeSelf,
|
||||
OnOk = _onOk,
|
||||
OnCancel = _onCancel
|
||||
};
|
||||
|
||||
public void RestoreState(ViewState state)
|
||||
{
|
||||
Show(state.Title, state.Message, state.OnOk, state.OnCancel, state.ShowCancel);
|
||||
if (!state.Visible) Hide();
|
||||
}
|
||||
|
||||
public void Bind()
|
||||
{
|
||||
@@ -27,6 +53,15 @@ namespace HotUpdate.UI
|
||||
_ok = FindBtn("Btn_Ok");
|
||||
_cancel = FindBtn("Btn_Cancel");
|
||||
_close = FindBtn("Btn_Close");
|
||||
if (_ok != null)
|
||||
{
|
||||
_okRect = _ok.GetComponent<RectTransform>();
|
||||
_okAnchorMin = _okRect.anchorMin;
|
||||
_okAnchorMax = _okRect.anchorMax;
|
||||
_okPivot = _okRect.pivot;
|
||||
_okPosition = _okRect.anchoredPosition;
|
||||
}
|
||||
_messageScroll = GetComponentInChildren<ScrollRect>(true);
|
||||
|
||||
Wire(_ok, () => { _onOk?.Invoke(); Hide(); });
|
||||
Wire(_cancel, () => { _onCancel?.Invoke(); Hide(); });
|
||||
@@ -45,6 +80,18 @@ namespace HotUpdate.UI
|
||||
_onOk = onOk;
|
||||
_onCancel = onCancel;
|
||||
if (_cancel != null) _cancel.gameObject.SetActive(showCancel);
|
||||
if (_okRect != null)
|
||||
{
|
||||
_okRect.anchorMin = showCancel ? _okAnchorMin : new Vector2(0.5f, _okAnchorMin.y);
|
||||
_okRect.anchorMax = showCancel ? _okAnchorMax : new Vector2(0.5f, _okAnchorMax.y);
|
||||
_okRect.pivot = showCancel ? _okPivot : new Vector2(0.5f, _okPivot.y);
|
||||
_okRect.anchoredPosition = showCancel ? _okPosition : new Vector2(0, _okPosition.y);
|
||||
}
|
||||
if (_messageScroll != null && _messageScroll.content != null)
|
||||
{
|
||||
_messageScroll.StopMovement();
|
||||
_messageScroll.content.anchoredPosition = Vector2.zero;
|
||||
}
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ using UnityEngine.UI;
|
||||
namespace HotUpdate.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// UI_MainHUD 运行时绑定。
|
||||
/// 营地主界面:绑定 JSON Prefab 入口、网络状态和断线重连。
|
||||
/// </summary>
|
||||
public sealed class MainHudView : MonoBehaviour
|
||||
{
|
||||
@@ -19,6 +19,7 @@ namespace HotUpdate.UI
|
||||
Button _reconnectBtn;
|
||||
|
||||
public event Action OnReconnectClicked;
|
||||
public event Action<string> OnHomeActionClicked;
|
||||
|
||||
public void Bind()
|
||||
{
|
||||
@@ -35,6 +36,16 @@ namespace HotUpdate.UI
|
||||
_reconnectBtn.onClick.AddListener(() => OnReconnectClicked?.Invoke());
|
||||
}
|
||||
ShowReconnect(false);
|
||||
foreach (var button in GetComponentsInChildren<Button>(true))
|
||||
{
|
||||
if (button == _reconnectBtn) 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;
|
||||
if (key == null) continue;
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(() => OnHomeActionClicked?.Invoke(key));
|
||||
}
|
||||
}
|
||||
|
||||
public void SetStatus(string text)
|
||||
@@ -47,6 +58,54 @@ namespace HotUpdate.UI
|
||||
if (_pid != null) _pid.text = pid > 0 ? $"pid={pid}" : "pid=-";
|
||||
}
|
||||
|
||||
public void SetPrototypeState(Gameplay.PrototypeSave state, Gameplay.PrototypeConfig config = null, long serverNow = 0)
|
||||
{
|
||||
SetText("Txt_Level", "Lv. " + state.level);
|
||||
SetText("Txt_Coin", state.coins.ToString("N0"));
|
||||
SetText("Txt_Diamond", "0");
|
||||
SetText("Txt_Pinballs", state.balls.ToString());
|
||||
SetText("Txt_TaskCaption", "教学目标");
|
||||
SetText("Txt_Task", state.goalClaimed ? "教学目标已完成" : state.cleared ? "领取首通目标奖励" : state.shots == 0 ? "发射第一枚弹珠" : state.level < 2 ? "共享训练至 Lv.2" : "完成教学关卡 1-1");
|
||||
SetText("Txt_AdventureHint", state.cleared ? "首通已完成 · 可重复挑战" : "首通另获糖果 40 · 不消耗体力");
|
||||
if (config != null && state.schema >= 2)
|
||||
{
|
||||
var stage = config.Stage(state.UnlockedStage); bool complete = state.clearedStages[stage.id - 1] == 1;
|
||||
SetText("Txt_Chapter", config.chapter);
|
||||
SetText("Txt_Stage", stage.code + " · " + stage.name);
|
||||
SetText("Txt_Reward_coin", complete ? "已领取" : stage.reward.coins.ToString());
|
||||
SetText("Txt_Reward_pinball", complete ? "已领取" : stage.reward.balls.ToString());
|
||||
SetText("Txt_AdventureHint", complete ? "本轮五关已完成 · 可免费重试" : "首通另获糖果 " + stage.reward.candy + " · 不消耗体力");
|
||||
}
|
||||
foreach (var child in GetComponentsInChildren<Transform>(true))
|
||||
if (child.name.StartsWith("Dot_", StringComparison.Ordinal)) child.gameObject.SetActive(false);
|
||||
if (state.mails != null)
|
||||
foreach (var child in GetComponentsInChildren<Transform>(true))
|
||||
if (child.name == "Dot_Mail") child.gameObject.SetActive(Array.Exists(state.mails, m => !m.expired && serverNow < m.expiresAt && (!m.read || !m.claimed && m.HasAttachment)));
|
||||
if (config?.rewards?.chests != null && state.chests != null)
|
||||
foreach (var child in GetComponentsInChildren<Transform>(true))
|
||||
if (child.name == "Dot_Rewards") child.gameObject.SetActive(state.chestDailyClaimed == 0 || Array.Exists(state.chests, n => n > 0));
|
||||
if (state.schema >= 5 && config?.daily != null)
|
||||
{
|
||||
int ready = 0; bool signin = false;
|
||||
for (int i = 0; i < 3; i++) if (state.dailyClaimed[i] == 0 && state.dailyProgress[i] >= config.daily.tasks[i].target) ready++;
|
||||
for (int i = 0; i < state.signinDays; i++) if (state.signinClaimed[i] == 0) signin = true;
|
||||
foreach (var child in GetComponentsInChildren<Transform>(true))
|
||||
if (child.name == "Dot_Tasks") child.gameObject.SetActive(ready > 0 || state.cleared && !state.goalClaimed);
|
||||
else if (child.name == "Dot_Daily") child.gameObject.SetActive(signin);
|
||||
if (state.goalClaimed)
|
||||
{
|
||||
SetText("Txt_TaskCaption", "每日目标");
|
||||
SetText("Txt_Task", ready > 0 ? ready + "项奖励可领取" : "查看今日任务进度");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SetText(string name, string value)
|
||||
{
|
||||
var label = FindTmp(name);
|
||||
if (label != null) label.text = value;
|
||||
}
|
||||
|
||||
public void ShowReconnect(bool visible, string message = null)
|
||||
{
|
||||
if (_reconnectPanel != null) _reconnectPanel.SetActive(visible);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -14,28 +15,46 @@ namespace HotUpdate.UI
|
||||
readonly Dictionary<string, GameObject> _opened = new Dictionary<string, GameObject>(StringComparer.Ordinal);
|
||||
Transform _root;
|
||||
UiPrefabLoader _loader;
|
||||
readonly HashSet<string> _loading = new HashSet<string>(StringComparer.Ordinal);
|
||||
public string Theme { get; private set; } = UiThemeCatalog.Candy;
|
||||
|
||||
public void Init(Transform root, UiPrefabLoader loader)
|
||||
public void Init(Transform root, UiPrefabLoader loader, string theme = UiThemeCatalog.Candy)
|
||||
{
|
||||
Instance = this;
|
||||
_root = root;
|
||||
_loader = loader ?? new UiPrefabLoader();
|
||||
Theme = UiThemeCatalog.Normalize(theme);
|
||||
}
|
||||
|
||||
public GameObject Open(string prefabName, int siblingIndex = -1)
|
||||
public IEnumerator OpenAsync(string prefabName, Action<GameObject> onOpened, int siblingIndex = -1)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefabName)) return null;
|
||||
if (string.IsNullOrEmpty(prefabName)) { onOpened?.Invoke(null); yield break; }
|
||||
while (_loading.Contains(prefabName)) yield return null;
|
||||
if (_opened.TryGetValue(prefabName, out var existing) && existing != null)
|
||||
{
|
||||
existing.SetActive(true);
|
||||
return existing;
|
||||
onOpened?.Invoke(existing);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var prefab = _loader.Load(prefabName);
|
||||
_loading.Add(prefabName);
|
||||
GameObject prefab = null;
|
||||
try
|
||||
{
|
||||
var assetName = UiThemeCatalog.Resolve(prefabName, Theme);
|
||||
yield return _loader.LoadAsync(assetName, loaded => prefab = loaded);
|
||||
if (prefab == null && assetName != prefabName)
|
||||
{
|
||||
Debug.LogWarning($"[UI] Missing {assetName}; falling back to {prefabName}. Include both themes in the resource package.");
|
||||
yield return _loader.LoadAsync(prefabName, loaded => prefab = loaded);
|
||||
}
|
||||
}
|
||||
finally { _loading.Remove(prefabName); }
|
||||
if (prefab == null)
|
||||
{
|
||||
Debug.LogError($"[UiManager] prefab not found: {prefabName}");
|
||||
return null;
|
||||
onOpened?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var go = Instantiate(prefab, _root, false);
|
||||
@@ -43,13 +62,7 @@ namespace HotUpdate.UI
|
||||
if (siblingIndex >= 0)
|
||||
go.transform.SetSiblingIndex(siblingIndex);
|
||||
_opened[prefabName] = go;
|
||||
return go;
|
||||
}
|
||||
|
||||
public T Open<T>(string prefabName) where T : Component
|
||||
{
|
||||
var go = Open(prefabName);
|
||||
return go != null ? go.GetComponentInChildren<T>(true) : null;
|
||||
onOpened?.Invoke(go);
|
||||
}
|
||||
|
||||
public void Close(string prefabName, bool destroy = false)
|
||||
@@ -72,6 +85,8 @@ namespace HotUpdate.UI
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
StopAllCoroutines();
|
||||
_loader?.Dispose();
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,90 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using YooAsset;
|
||||
|
||||
namespace HotUpdate.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// UI Prefab 加载:编辑器 AssetDatabase;运行时优先 DefaultPackage 寻址。
|
||||
/// 异步加载并持有资源句柄,随 UiManager 销毁统一释放。
|
||||
/// </summary>
|
||||
public sealed class UiPrefabLoader
|
||||
public sealed class UiPrefabLoader : IDisposable
|
||||
{
|
||||
public const string PrefabFolder = "Assets/Bundles/UI/Prefab";
|
||||
readonly ResourcePackage _preferredPackage;
|
||||
readonly string _folder;
|
||||
readonly Dictionary<string, GameObject> _cache = new Dictionary<string, GameObject>();
|
||||
readonly List<AssetHandle> _handles = new List<AssetHandle>();
|
||||
bool _disposed;
|
||||
|
||||
public GameObject Load(string prefabName)
|
||||
public UiPrefabLoader(ResourcePackage preferredPackage = null, string folder = PrefabFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefabName)) return null;
|
||||
_preferredPackage = preferredPackage;
|
||||
_folder = folder;
|
||||
}
|
||||
|
||||
public IEnumerator LoadAsync(string prefabName, Action<GameObject> onLoaded)
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(prefabName))
|
||||
{
|
||||
onLoaded?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
if (_cache.TryGetValue(prefabName, out var cached) && cached != null)
|
||||
{
|
||||
onLoaded?.Invoke(cached);
|
||||
yield break;
|
||||
}
|
||||
var packages = new List<ResourcePackage>();
|
||||
if (_preferredPackage != null) packages.Add(_preferredPackage);
|
||||
if (YooAssets.IsInitialized)
|
||||
{
|
||||
// DefaultPackage 或任意已初始化包
|
||||
var packages = new[] { "DefaultPackage", "Art" };
|
||||
foreach (var name in packages)
|
||||
foreach (var name in new[] { "DefaultPackage", "Art" })
|
||||
if (YooAssets.TryGetPackage(name, out var pkg) && !packages.Contains(pkg))
|
||||
packages.Add(pkg);
|
||||
}
|
||||
foreach (var package in packages)
|
||||
{
|
||||
if (package.InitializeStatus != EOperationStatus.Succeeded) continue;
|
||||
foreach (var address in new[] { prefabName, $"{_folder}/{prefabName}.prefab" })
|
||||
{
|
||||
if (!YooAssets.TryGetPackage(name, out var pkg)) continue;
|
||||
if (pkg.InitializeStatus != EOperationStatus.Succeeded) continue;
|
||||
var handle = pkg.LoadAssetSync<GameObject>(prefabName);
|
||||
if (!package.IsLocationValid(address)) continue;
|
||||
var handle = package.LoadAssetAsync<GameObject>(address);
|
||||
_handles.Add(handle);
|
||||
yield return handle;
|
||||
if (_disposed) yield break;
|
||||
if (handle.Status == EOperationStatus.Succeeded)
|
||||
return handle.GetAssetObject<GameObject>();
|
||||
{
|
||||
var prefab = handle.GetAssetObject<GameObject>();
|
||||
if (prefab != null)
|
||||
{
|
||||
_cache[prefabName] = prefab;
|
||||
onLoaded?.Invoke(prefab);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
_handles.Remove(handle);
|
||||
handle.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
var path = $"{PrefabFolder}/{prefabName}.prefab";
|
||||
return UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(path);
|
||||
var editorPrefab = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>($"{_folder}/{prefabName}.prefab");
|
||||
if (editorPrefab != null) _cache[prefabName] = editorPrefab;
|
||||
onLoaded?.Invoke(editorPrefab);
|
||||
#else
|
||||
return null;
|
||||
onLoaded?.Invoke(null);
|
||||
#endif
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var handle in _handles) handle.Release();
|
||||
_handles.Clear();
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace HotUpdate.UI
|
||||
{
|
||||
/// <summary>Candy UI;旧启动配置中的其他主题值回退到 Candy。</summary>
|
||||
public static class UiThemeCatalog
|
||||
{
|
||||
public const string Candy = "candy";
|
||||
|
||||
public static string Normalize(string theme)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(theme)) return Candy;
|
||||
theme = theme.Trim().ToLowerInvariant();
|
||||
if (theme == Candy) return Candy;
|
||||
Debug.LogWarning($"[UI] Unknown theme '{theme}', using candy.");
|
||||
return Candy;
|
||||
}
|
||||
|
||||
public static string Resolve(string logicalName, string theme)
|
||||
{
|
||||
return logicalName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69126c9c801a65e4e8a3c7c0c8bfc88e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user