Add prototype gameplay loop with character/battlefield assets and server game APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-20 18:50:00 +08:00
co-authored by Cursor
parent 79369e4473
commit 6c64210331
833 changed files with 998079 additions and 1042 deletions
@@ -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: