Add formal enemies, remaining formal partners and synthesized UI SFX

- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to
  buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting.
- Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn):
  Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type
  in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes.
- Formal partners for all ten pets in the arena and portraits, including new
  VineSprite, ThunderLeopard and TideWhale.
- Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands,
  configurable Unity run_script timeout, companion static stage.
- Also includes concurrent work from another tool in this working tree
  (FlameLion, enemy combat FX, challenge mode, home explorer, server updates).
- Resources registered in Doc/ResDesc_*.md; verified on isolated :18081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
udx
2026-10-21 12:23:55 +08:00
co-authored by Claude Opus 5.5
parent 6c64210331
commit 89e0fa246f
780 changed files with 7117051 additions and 15906 deletions
@@ -0,0 +1,84 @@
using System;
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>Authored bounded VFX/audio pools; consumes resolved facts without touching battle state.</summary>
public sealed class EnemyCombatPresentation : MonoBehaviour
{
[Serializable] public sealed class EffectPool
{
public ParticleSystem[] instances;
[NonSerialized] public int next;
}
[SerializeField] EffectPool[] _effects;
[SerializeField] AudioClip[] _clips;
[SerializeField] AudioSource[] _voices;
[SerializeField, Range(0, 1)] float _mixBudget = .7f;
[SerializeField, Range(0, 1)] float _voiceVolume = .4f;
readonly float[] _lastSound = new float[8];
int _voice;
bool _paused;
float _speed = 1;
public int PlayedEffects { get; private set; }
public int PlayedSounds { get; private set; }
public int EffectCount => _effects?.Length ?? 0;
public AudioClip Clip(int index) => _clips[index];
public int VoiceCount => _voices?.Length ?? 0;
void OnEnable() { PrototypeClientPrefs.AudioPreferencesChanged += ApplyAudio; ResetPlayback(); }
void OnDisable() { PrototypeClientPrefs.AudioPreferencesChanged -= ApplyAudio; ResetPlayback(); }
void ApplyAudio() { if (!PrototypeClientPrefs.Sfx) foreach (var v in _voices) v.Stop(); }
public void ResetPlayback()
{
if (_effects != null) foreach (var pool in _effects)
foreach (var ps in pool.instances) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
if (_voices != null) foreach (var v in _voices) v.Stop();
for (int i = 0; i < _lastSound.Length; i++) _lastSound[i] = -100;
_paused = false; _speed = 1; PlayedEffects = PlayedSounds = 0;
}
public void SetPlayback(bool paused, float speed)
{
if (_paused == paused && Mathf.Approximately(_speed, speed)) return;
bool speedChanged = !Mathf.Approximately(_speed, speed);
_speed = speed;
foreach (var pool in _effects) foreach (var ps in pool.instances)
{
if (speedChanged) foreach (var child in ps.GetComponentsInChildren<ParticleSystem>()) { var m = child.main; m.simulationSpeed = speed; }
if (paused && !_paused) ps.Pause(true);
else if (!paused && _paused && ps.IsAlive(true)) ps.Play(true);
}
if (paused != _paused) foreach (var v in _voices) { if (paused) v.Pause(); else v.UnPause(); }
_paused = paused;
}
public void Play(PrototypeBattle.CombatCue cue)
{
if (_paused || _effects == null) return;
int index = cue.Kind == PrototypeBattle.CueKind.EnemyAttack ? Mathf.Clamp(cue.Type, 0, 4) : (cue.Type >= 2 ? 6 : 5);
Burst(index, cue.Kind == PrototypeBattle.CueKind.EnemyAttack ? cue.AttackPosition : cue.Position, cue.Direction);
if (cue.Kind == PrototypeBattle.CueKind.EnemyAttack)
Burst(cue.Shield ? 7 : 5, cue.Position, -cue.Direction);
}
void Burst(int index, Vector3 position, Vector3 direction)
{
var pool = _effects[index];
var ps = pool.instances[pool.next++ % pool.instances.Length];
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ps.transform.position = transform.TransformPoint(position);
ps.transform.rotation = Quaternion.LookRotation(direction);
foreach (var child in ps.GetComponentsInChildren<ParticleSystem>()) { var m = child.main; m.simulationSpeed = _speed; }
ps.Play(true); PlayedEffects++;
// Mix budget and same-cue guard prevent a wave of hits becoming a wall of sound.
if (!PrototypeClientPrefs.Sfx || Time.unscaledTime - _lastSound[index] < .075f) return;
_lastSound[index] = Time.unscaledTime;
var voice = _voices[_voice++ % _voices.Length]; voice.Stop(); voice.clip = _clips[index]; voice.Play(); PlayedSounds++;
int active = 0; foreach (var v in _voices) if (v.isPlaying) active++;
float gain = Mathf.Min(_voiceVolume, _mixBudget / Mathf.Max(1, active));
foreach (var v in _voices) if (v.isPlaying) v.volume = gain;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b8010ff22c04f745bd084a9ef83d3bd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,34 @@
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>Placeholder explorer: swings the authored legs while the root is moved.</summary>
public sealed class ExplorerPuppet : MonoBehaviour
{
Transform _legL, _legR, _body;
Vector3 _last;
Vector3 _bodyRest;
void Awake()
{
_legL = transform.Find("LegL");
_legR = transform.Find("LegR");
_body = transform.Find("Body");
if (_body != null) _bodyRest = _body.localPosition;
_last = transform.position;
}
void LateUpdate()
{
Vector3 delta = transform.position - _last;
_last = transform.position;
float speed = new Vector2(delta.x, delta.z).magnitude / Mathf.Max(Time.deltaTime, 0.0001f);
bool walking = speed > 0.05f;
float swing = walking ? Mathf.Sin(Time.time * 8f) * 32f : 0f;
if (_legL != null) _legL.localRotation = Quaternion.Euler(swing, 0, 0);
if (_legR != null) _legR.localRotation = Quaternion.Euler(-swing, 0, 0);
if (_body != null)
_body.localPosition = _bodyRest + Vector3.up * (walking ? Mathf.Abs(Mathf.Sin(Time.time * 8f)) * 0.05f : Mathf.Sin(Time.time * 2f) * 0.015f);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 82c61f930527bf343a6ba5b83e7925ee
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -16,7 +16,7 @@ namespace HotUpdate.Gameplay
ConfirmDialogView _dialog;
PrototypeFlow _prototype;
UiPrefabLoader _musicLoader;
GameObject _musicInstance;
GameObject _musicInstance, _sfxInstance;
bool _maintenanceLock;
string _maintenanceMessage;
string _reconnectMessage;
@@ -79,31 +79,41 @@ namespace HotUpdate.Gameplay
_prototype = gameObject.AddComponent<PrototypeFlow>();
_prototype.Bind(_ctx, mgr, _hud, _dialog, () => _net?.Session, () => _net?.RenewSession());
_prototype.SetLocked(_maintenanceLock);
StartCoroutine(EnsureMusic());
StartCoroutine(EnsureAudio());
if (_maintenanceLock) _dialog?.Show("维护公告", _maintenanceMessage);
else MaybeShowBootNotice();
}
System.Collections.IEnumerator EnsureMusic()
/// <summary>Music and UI SFX prefabs share the Sound/Prefab folder and one loader.</summary>
System.Collections.IEnumerator EnsureAudio()
{
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.");
else
{
_musicInstance = Instantiate(prefab, transform, false);
_musicInstance.name = MainMenuMusic.PrefabName;
_prototype?.BindMusic(_musicInstance.GetComponent<MainMenuMusic>());
}
prefab = null;
yield return _musicLoader.LoadAsync(UiSfx.PrefabName, loaded => prefab = loaded);
if (prefab == null)
{
Debug.LogWarning("[MainMenuMusic] Music prefab unavailable in the current Art package.");
Debug.LogWarning("[UiSfx] UI sound prefab unavailable in the current Art package.");
yield break;
}
_musicInstance = Instantiate(prefab, transform, false);
_musicInstance.name = MainMenuMusic.PrefabName;
_prototype?.BindMusic(_musicInstance.GetComponent<MainMenuMusic>());
_sfxInstance = Instantiate(prefab, transform, false);
_sfxInstance.name = UiSfx.PrefabName;
}
void OnDestroy()
{
StopAllCoroutines();
if (_musicInstance != null) Destroy(_musicInstance);
if (_sfxInstance != null) Destroy(_sfxInstance);
_musicLoader?.Dispose();
}
@@ -25,7 +25,7 @@ namespace HotUpdate.Gameplay
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 BattleSnapshot { public int level, stageId, challengeId; 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; }
@@ -41,12 +41,11 @@ namespace HotUpdate.Gameplay
[Serializable] public sealed class PrototypeResult
{
public string id;
public int pet, durability, defeated;
public bool duplicate, won, firstClear, tutorial, pity;
public int pet, durability, defeated, stageId, challengeId, bestMs;
public bool duplicate, won, firstClear, tutorial, pity, improved;
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;
@@ -57,6 +56,13 @@ namespace HotUpdate.Gameplay
public BattleHeroStats[] stats;
public int[] leaks;
}
[Serializable] public sealed class RankEntry { public long pid, at; public int score, place; public string name; }
[Serializable] public sealed class RankBoard
{
public int board, total, selfPlace, selfScore;
public long selfAt;
public RankEntry[] entries;
}
[Serializable] public sealed class PrototypeReply
{
public bool ok, replayed;
@@ -72,6 +78,7 @@ namespace HotUpdate.Gameplay
public CaptureStatus capture;
public DailyStatus daily;
public RewardsStatus rewards;
public RankBoard rank;
}
/// <summary>One durable request at a time, scoped to endpoint + authenticated player. Never stores tokens.</summary>
@@ -132,6 +139,21 @@ namespace HotUpdate.Gameplay
// 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 ValidRank(PrototypeReply reply, PrototypeCommand request)
{
if (!reply.ok) return reply.rank == null;
var board = reply.rank;
if (board == null || board.board != request.stageId || board.board < 0 || board.board > 5 || board.total < 0 || board.selfPlace < 0 || board.selfScore < 0 || board.selfAt < 0) return false;
if (board.entries == null) board.entries = new RankEntry[0];
if (board.entries.Length > 20 || board.entries.Length > board.total || (board.selfPlace == 0) != (board.selfScore == 0)) return false;
if (board.selfPlace > board.total) return false;
for (int i = 0; i < board.entries.Length; i++)
{
var row = board.entries[i];
if (row == null || row.pid <= 0 || string.IsNullOrEmpty(row.name) || row.name.Length > 64 || row.score <= 0 || row.at < 0 || row.place != i + 1) return false;
}
return true;
}
static bool ValidChestReceipt(PrototypeReply reply, PrototypeCommand request)
{
if (reply.action != request.action) return false;
@@ -181,7 +203,7 @@ namespace HotUpdate.Gameplay
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)
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 > 11 || (reply.state.schema >= 10 && reply.config.challenges?.Length != 5) || (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;
@@ -194,6 +216,8 @@ namespace HotUpdate.Gameplay
{ 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 (request.action == "rank.list" && !ValidRank(reply, request))
{ done(null, "network_unavailable"); yield break; }
}
if (body != null) { PlayerPrefs.DeleteKey(_key); PlayerPrefs.Save(); }
done(reply, null);
@@ -22,7 +22,27 @@ namespace HotUpdate.Gameplay
public float Hp, MaxHp, Z = 11, Cooldown, Pulse;
public bool Alive => Hp > 0;
public float SlowSeconds, SlowFactor = 1;
// Presentation only (walk/attack animation); never read by the simulation.
public bool Moving;
public int Strikes;
}
// Presentation facts only. Consumers drain after every fixed step; never feed back into damage.
public enum CueKind { EnemyAttack, EnemyHit }
public readonly struct CombatCue
{
public readonly CueKind Kind;
public readonly int Type;
public readonly Vector3 Position, Direction, AttackPosition;
public readonly bool Shield;
public CombatCue(CueKind kind, int type, Vector3 position, Vector3 direction, bool shield = false, Vector3? attackPosition = null)
{ Kind = kind; Type = type; Position = position; Direction = direction; Shield = shield; AttackPosition = attackPosition ?? position; }
}
readonly Queue<CombatCue> _cues = new Queue<CombatCue>();
public bool TryDequeueCue(out CombatCue cue)
{ if (_cues.Count > 0) { cue = _cues.Dequeue(); return true; } cue = default; return false; }
public static Vector3 EnemyPosition(Enemy e) => new Vector3(LaneX(e.Lane) + Mathf.Sin(e.Index * 1.3f) * .38f, 0, e.Z);
void HitCue(Enemy e) => _cues.Enqueue(new CombatCue(CueKind.EnemyHit, e.Type, EnemyPosition(e) + Vector3.up * (e.Boss ? 1.05f : .7f) + Vector3.back * .5f, Vector3.forward));
void StrikeCue(Enemy e, Hero h, bool shield = false) => _cues.Enqueue(new CombatCue(CueKind.EnemyAttack, e.Type, new Vector3(LaneX(h.Lane), .9f, h.Z - .5f), Vector3.back, shield, EnemyPosition(e) + new Vector3(0, e.Boss ? 1.05f : .8f, -.55f)));
public const float Step = .05f;
public readonly string Id;
public readonly List<Hero> Heroes = new List<Hero>();
@@ -43,10 +63,10 @@ namespace HotUpdate.Gameplay
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)
public PrototypeBattle(string id, PrototypeGame game, PrototypeConfig config = null, int stageId = 1, StageConfig stage = null)
{
if (string.IsNullOrEmpty(id)) throw new ArgumentException("Battle id required.");
Id = id; _config = config; StageId = stageId; Stage = config?.Stage(stageId);
Id = id; _config = config; StageId = stageId; Stage = stage ?? config?.Stage(stageId);
if (Stage != null) Durability = Stage.durability;
for (int slot = 0; slot < LaneCount * 2; slot++)
{
@@ -71,7 +91,7 @@ namespace HotUpdate.Gameplay
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;
hero.DamageDealt += Mathf.Min(enemy.Hp, damage); enemy.Hp -= damage; HitCue(enemy);
enemy.Pulse = .2f; if (!enemy.Alive) Defeated++;
}
void Ability(Hero hero)
@@ -139,6 +159,7 @@ namespace HotUpdate.Gameplay
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;
enemy.Moving = enemy.Z > stop;
if (enemy.Z > stop) enemy.Z = Mathf.Max(stop, enemy.Z - d.speed * enemy.SlowFactor * Step);
else if (blocker != null)
{
@@ -147,7 +168,7 @@ namespace HotUpdate.Gameplay
{
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;
blocker.Hp = Mathf.Max(0, blocker.Hp - (damage - absorbed)); enemy.Cooldown = d.cooldown; enemy.Pulse = .2f; enemy.Strikes++; StrikeCue(enemy, blocker, absorbed >= damage);
}
}
else { Durability -= d.leak; Leaks[enemy.Lane]++; enemy.Hp = 0; }
@@ -186,7 +207,7 @@ namespace HotUpdate.Gameplay
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;
HitCue(nearest); 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++;
}
}
@@ -198,11 +219,12 @@ namespace HotUpdate.Gameplay
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;
enemy.Moving = enemy.Z > stopZ;
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; }
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; enemy.Strikes++; StrikeCue(enemy, blocker); }
}
else
{
@@ -67,6 +67,22 @@ namespace HotUpdate.Gameplay
public int[] enemyLanes;
public StageReward reward;
}
[Serializable] public sealed class ChallengeConfig
{
public int id, allyElement, element, durability, recommendedLevel, shardsPet, shards, lanes;
public string code, name, hint, badge, obtain, chapter;
public bool elementsEnabled;
public float hpScale, attackScale, waveGap, spawnGap, timeout;
public int[] enemyIds, enemyLanes;
public StageReward reward;
public StageConfig ToStage() => new StageConfig
{
id = id, recommendedLevel = recommendedLevel, element = element, durability = durability,
code = code, name = name, hint = hint, chapter = string.IsNullOrEmpty(chapter) ? "属性挑战" : chapter,
elementsEnabled = elementsEnabled, hpScale = hpScale, attackScale = attackScale, waveGap = waveGap, spawnGap = spawnGap, timeout = timeout,
enemyIds = enemyIds, lanes = lanes, enemyLanes = enemyLanes, reward = reward
};
}
/// <summary>Received from the server; the only authoring source is Config/Gameplay/greenfield.json.</summary>
[Serializable] public sealed class PrototypeConfig
{
@@ -84,6 +100,7 @@ namespace HotUpdate.Gameplay
public PetConfig[] pets;
public EnemyConfig[] enemies;
public StageConfig[] stages;
public ChallengeConfig[] challenges;
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)
@@ -172,6 +189,20 @@ namespace HotUpdate.Gameplay
if (a.kind == "slow" && (a.bossControlScale < 0 || a.bossControlScale > 1)) return false;
}
}
if (c.challenges != null && c.challenges.Length > 0)
{
if (c.challenges.Length != 5) return false;
for (int i = 0; i < 5; i++)
{
var challenge = c.challenges[i];
if (challenge == null || challenge.id != i + 1 || challenge.lanes != 1 || challenge.allyElement < 1 || challenge.allyElement > 5 || challenge.element < 1 || challenge.element > 5 || challenge.allyElement == challenge.element) return false;
if (challenge.enemyIds?.Length != 9 || challenge.enemyLanes?.Length != 9 || challenge.hpScale <= 0 || challenge.timeout <= 0 || challenge.durability < 1) return false;
if (challenge.shards < 1 || challenge.shards > 100 || challenge.shardsPet < 0 || challenge.shardsPet >= c.pets.Length || c.pets[challenge.shardsPet].element != challenge.allyElement) return false;
if (string.IsNullOrEmpty(challenge.badge) || string.IsNullOrEmpty(challenge.obtain) || string.IsNullOrEmpty(challenge.code)) return false;
foreach (int enemy in challenge.enemyIds) if (enemy < 0 || enemy >= c.enemies.Length) return false;
foreach (int lane in challenge.enemyLanes) if (lane != 0) return false;
}
}
return true;
}
public float Growth(PrototypeSave save, int pet) => (1 + economy.levelGrowth * (save.level - 1)) * (save.stages[pet] == 2 ? economy.evolveGrowth : 1);
@@ -34,7 +34,8 @@ namespace HotUpdate.Gameplay
public Transform Node(string name) => _nodes[name];
public void Wire(string name, Action callback)
{
var b = Button(name); b.onClick.RemoveAllListeners(); b.onClick.AddListener(() => callback());
var b = Button(name); var sound = UiSfx.ForButton(name);
b.onClick.RemoveAllListeners(); b.onClick.AddListener(() => { UiSfx.Play(sound); callback(); });
}
}
UiManager _manager;
@@ -57,8 +58,18 @@ namespace HotUpdate.Gameplay
readonly Dictionary<string, Panel> _panels = new Dictionary<string, Panel>();
Panel _page;
GameObject _arena;
EnemyCombatPresentation _enemyFeedback;
Transform[] _heroViews, _enemyViews, _projectiles;
Transform[] _skillViews, _shieldViews;
// Formal partners carry an Animator (Model node: Motion int, Attack trigger); primitive stand-ins do not.
Animator[] _heroAnimators;
float[] _heroPulse, _heroSkillPulse;
// Formal enemy art: Enemies/EnemyN/Type{t} per enemy type, with the model that last drove each slot and its strike count.
Transform[][] _enemyTypes;
Animator[][] _enemyAnimators;
PrototypeBattle.Enemy[] _enemyModels;
int[] _enemyStrikes;
int _homeDots = -1;
int _dragPet = -1, _dragSlot = -1;
PrototypeGame _game;
PrototypeBattle _battle;
@@ -71,7 +82,8 @@ namespace HotUpdate.Gameplay
int _mailPage;
string _mailSelected;
int _chestChoice = -1;
int _stageTab;
int _stageTab, _challenge, _rankBoard;
RankBoard _rank;
double _receivedAt, _restGreetingUntil; long _serverUnix;
float _campRefresh;
bool _opening, _paused, _settled, _locked, _abandonRequested;
@@ -100,7 +112,7 @@ namespace HotUpdate.Gameplay
long pid = _profile?.Invoke() ?? 0;
if (pid <= 0 || pid == _boundProfile) return;
Home(); _boundProfile = pid; _mailPage = 0; _mailSelected = null;
_game = null; _reply = null; _needsSync = true; _busy = false;
_game = null; _homeDots = -1; _reply = null; _needsSync = true; _busy = false;
_api = new PrototypeApi(_httpBase, pid, _session, _reauthenticate);
StartCoroutine(Synchronize());
}
@@ -108,7 +120,7 @@ namespace HotUpdate.Gameplay
{
switch (code)
{
case "insufficient_balls": return "弹珠不足,每10分钟自然恢复一枚。";
case "insufficient_balls": return "弹珠不足。每10分钟自然恢复一枚,也可领取每日任务、签到和免费补给。";
case "mail_not_found": return "这封邮件已不可用,请刷新后重试。";
case "mail_expired": return "邮件已到期,附件无法领取。";
case "mail_already_claimed": return "这封邮件的附件已经领取。";
@@ -127,6 +139,10 @@ namespace HotUpdate.Gameplay
case "pending_activity": return "请先完成正在进行的弹珠或挑战。";
case "unauthorized": return "登录暂时无法恢复,请稍后重试或重新进入游戏。";
case "maintenance": return "服务器维护中,请稍后重连。";
case "challenge_locked": return "通过 1-1 后才能进入属性试炼。";
case "need_element": return "还没有这个元素的伙伴。用捕捉券在伙伴池结识一只即可参加,不需要付费。";
case "invalid_challenge": return "这场试炼暂时不可用,请返回后重试。";
case "invalid_board": return "这块排行榜暂时不可用,请返回后重试。";
case "stage_locked": return "请先通过前一关,再挑战这一关。";
case "empty_formation": return "请在本关开放的路线部署伙伴。";
case "invalid_formation": return "站位不可用,请选择已拥有的伙伴和本关开放的路线。";
@@ -183,10 +199,13 @@ namespace HotUpdate.Gameplay
case "UI_PrototypeShop": RefreshShop(); break;
case "UI_PrototypeMail": RefreshMail(); break;
case "UI_PrototypeChest": RefreshChest(); break;
case "UI_PrototypeChallenges": RefreshChallenges(); break;
case "UI_PrototypeRank": RefreshRank(); break;
}
}
void Failed(string error)
{
UiSfx.Play(UiSound.Deny);
_needsSync = true; RefreshCurrent();
_dialog.Show("连接待恢复", Reason(error) + "\n未确认的操作已保留,点击确定重试。", () => StartCoroutine(Synchronize()));
}
@@ -251,17 +270,32 @@ namespace HotUpdate.Gameplay
_dialog.Show("恢复进行中的冒险", Reason(r.error), () => StartCoroutine(Synchronize()));
}
else _dialog.Show("操作未完成", Reason(r.error));
UiSfx.Play(UiSound.Deny);
yield break;
}
var feedback = ResultSound(action);
if (feedback.HasValue) UiSfx.Play(feedback.Value);
done?.Invoke(r);
}
/// <summary>Server-confirmed feedback; battle results pick their own sound in ShowBattleResult.</summary>
static UiSound? ResultSound(string action)
{
switch (action)
{
case "capture": case "goal.claim": case "daily.claim": case "signin.claim": case "mail.claim": case "mail.claimAll": case "chest.claimDaily": case "chest.open": return UiSound.Claim;
case "train": case "evolve": case "shop.buy": case "camp.place": case "camp.store": case "camp.interact": return UiSound.Confirm;
case "deploy": case "undeploy": case "autoDeploy": return UiSound.Toggle;
default: return null;
}
}
public bool Handle(string key)
{
if (key != "Pinball" && key != "Pinballs" && key != "Energy" && key != "Pets" && key != "Adventure" && key != "Battle" && key != "Home" && key != "Camp" && key != "Tasks" && key != "Daily" && key != "Bag" && key != "Shop" && key != "Profile" && key != "Coin" && key != "Diamond" && key != "Mail" && key != "Rewards" && key != "Settings") return false;
if (key != "Pinball" && key != "Pinballs" && key != "Energy" && key != "Pets" && key != "Adventure" && key != "Battle" && key != "Home" && key != "Camp" && key != "Tasks" && key != "Daily" && key != "Bag" && key != "Shop" && key != "Profile" && key != "Coin" && key != "Diamond" && key != "Mail" && key != "Rewards" && key != "Settings" && key != "Events" && key != "Rank") return false;
if (_locked || _opening || _busy) return true;
AttachProfile();
if (_needsSync) { StartCoroutine(Synchronize()); return true; }
if (_game == null) { _dialog.Show("正在连接", "登录完成后即可开始冒险,请稍候或使用重连按钮。"); return true; }
UiSfx.Play(key == "Home" ? UiSound.Close : UiSound.Open);
switch (key)
{
case "Home": Home(); break;
@@ -283,6 +317,14 @@ namespace HotUpdate.Gameplay
case "Shop": _shopTab = 0; StartCoroutine(Open("Shop")); break;
case "Coin": _shopTab = 1; StartCoroutine(Open("Shop")); break;
case "Settings": StartCoroutine(Open("Settings")); break;
case "Events":
if (_game.State.schema < 10 || _config.challenges == null || _config.challenges.Length != 5) _dialog.Show("属性挑战", "挑战服务正在更新,请稍后重新进入。");
else StartCoroutine(Open("Challenges"));
break;
case "Rank":
if (_game.State.schema < 11) _dialog.Show("冒险排行", "排行服务正在更新,请稍后重新进入。");
else { _rankBoard = 0; _rank = null; StartCoroutine(Open("Rank")); }
break;
default:
_dialog.Show(key == "Profile" ? "探险家档案" : "资源背包", "共享训练 Lv." + _game.State.level + "\n金币 " + _game.State.coins + " · 糖果 " + _game.State.candy + "\n弹珠 " + _game.State.balls + " · 捕捉券 " + _game.State.tickets + "\n伙伴 " + _game.State.stages.Count(s => s > 0) + "/" + _config.pets.Length);
break;
@@ -295,7 +337,15 @@ namespace HotUpdate.Gameplay
if (_locked && _page != null)
foreach (var button in _page.Go.GetComponentsInChildren<Button>(true)) button.interactable = false;
}
void RefreshHome() { if (_game != null) _hud.SetPrototypeState(_game.State, _config, ServerNow); }
void RefreshHome()
{
if (_game == null) return;
_hud.SetPrototypeState(_game.State, _config, ServerNow);
// Ping only when a new red dot appears on a visible HUD, not for dots already shown at login.
int dots = _hud.GetComponentsInChildren<Transform>(true).Count(t => t.name.StartsWith("Dot_", StringComparison.Ordinal) && t.gameObject.activeSelf);
if (_homeDots >= 0 && dots > _homeDots && _hud.gameObject.activeInHierarchy) UiSfx.Play(UiSound.Notify);
_homeDots = dots;
}
void Home()
{
if (_page != null) _page.Go.SetActive(false);
@@ -347,6 +397,23 @@ namespace HotUpdate.Gameplay
panel.Wire("GoStages", () => StartCoroutine(Open("Stages")));
RefreshChest();
}
else if (screen == "Challenges")
{
for (int i = 0; i < 5; i++) { int row = i; panel.Wire("Challenge" + i, () => { _challenge = row; RefreshChallenges(); }); }
panel.Wire("Start", () => StartCoroutine(StartChallenge()));
panel.Wire("Capture", () => StartCoroutine(Open("Capture")));
RefreshChallenges();
}
else if (screen == "Rank")
{
for (int i = 0; i < 6; i++)
{
int board = i;
panel.Wire("RankTab" + i, () => { if (!Blocked && _rankBoard != board) StartCoroutine(LoadRank(board)); });
}
RefreshRank();
StartCoroutine(LoadRank(_rankBoard));
}
else if (screen == "Settings")
{
panel.Wire("ToggleMusic", () => { if (Blocked) return; PrototypeClientPrefs.Music = !PrototypeClientPrefs.Music; RefreshSettings(); });
@@ -971,7 +1038,8 @@ namespace HotUpdate.Gameplay
IEnumerator StartBattle()
{
if (_opening || Blocked || !_game.State.formation.Any(p => p >= 0)) yield break;
bool pending = !string.IsNullOrEmpty(_game.State.pendingBattle);
if (_opening || Blocked || (!pending && !_game.State.formation.Any(p => p >= 0))) yield break;
var api = _api;
// Keep the last known playable formation before battle.begin. Some
// older pending tickets contain a snapshot without formation data;
@@ -987,12 +1055,19 @@ namespace HotUpdate.Gameplay
if (api != _api) yield break;
if (prefab == null) { _dialog.Show("战场加载失败", "请返回营地后重试。"); yield break; }
_arena = Instantiate(prefab, transform); _arena.name = "PrototypeArena_Runtime"; _arena.SetActive(false);
_enemyFeedback = _arena.GetComponentInChildren<EnemyCombatPresentation>(true);
int petCount = _config?.pets?.Length ?? PrototypeGame.Pets.Length;
_heroViews = Enumerable.Range(0, petCount).Select(i => _arena.transform.Find("Heroes/Hero" + i)).ToArray();
_enemyViews = Enumerable.Range(0, PrototypeBattle.TotalEnemies).Select(i => _arena.transform.Find("Enemies/Enemy" + i)).ToArray();
_projectiles = Enumerable.Range(0, petCount).Select(i => _arena.transform.Find("Projectiles/Shot" + i)).ToArray();
_skillViews = Enumerable.Range(0, petCount).Select(i => _arena.transform.Find("SkillEffects/Skill" + i)).ToArray();
_shieldViews = Enumerable.Range(0, petCount).Select(i => _arena.transform.Find("SkillEffects/Shield" + i)).ToArray();
_heroAnimators = _heroViews.Select(v => v == null ? null : v.GetComponentInChildren<Animator>(true)).ToArray();
_heroPulse = new float[petCount]; _heroSkillPulse = new float[petCount];
int enemyTypes = _config?.enemies?.Length ?? 5;
_enemyTypes = _enemyViews.Select(v => Enumerable.Range(0, enemyTypes).Select(t => v == null ? null : v.Find("Type" + t)).ToArray()).ToArray();
_enemyAnimators = _enemyTypes.Select(types => types.Select(t => t == null ? null : t.GetComponentInChildren<Animator>(true)).ToArray()).ToArray();
_enemyModels = new PrototypeBattle.Enemy[_enemyViews.Length]; _enemyStrikes = new int[_enemyViews.Length];
}
if (string.IsNullOrEmpty(_game.State.pendingBattle))
{
@@ -1022,10 +1097,22 @@ namespace HotUpdate.Gameplay
for (int i = 0; i < Mathf.Min(sourceFormation.Length, safeFormation.Length); i++)
safeFormation[i] = sourceFormation[i] >= 0 && sourceFormation[i] < petCount ? sourceFormation[i] : -1;
}
var snapshot = new PrototypeSave { schema = safeStages.Length == 5 ? 3 : 1, level = remote.snapshot.level, stages = safeStages, shards = new int[safeStages.Length], formation = safeFormation };
var snapshot = PrototypeSave.BattleSnapshot(remote.snapshot.level, safeStages, safeFormation);
var battleConfig = remote.snapshot.legacy ? null : (remote.snapshot.config ?? _config);
int battleStageId = remote.snapshot.legacy ? 1 : (remote.snapshot.stageId > 0 ? remote.snapshot.stageId : localStageId);
_battle = new PrototypeBattle(remote.id, new PrototypeGame(snapshot), battleConfig, battleStageId); _battleAccumulator = 0; _speed = 1; _paused = false; _settled = false; _abandonRequested = false;
StageConfig challengeStage = null;
int challengeId = remote.snapshot.challengeId;
if (challengeId > 0)
{
if (battleConfig?.challenges == null || challengeId > battleConfig.challenges.Length)
{
_dialog.Show("试炼数据缺失", "请返回营地后重新进入。", () => Home());
yield break;
}
challengeStage = battleConfig.challenges[challengeId - 1].ToStage();
}
_enemyFeedback?.ResetPlayback();
_battle = new PrototypeBattle(remote.id, new PrototypeGame(snapshot), battleConfig, challengeStage != null ? challengeId : battleStageId, challengeStage); _battleAccumulator = 0; _speed = 1; _paused = false; _settled = false; _abandonRequested = false;
yield return Open("Battle");
RefreshBattle();
}
@@ -1051,10 +1138,23 @@ namespace HotUpdate.Gameplay
if (_shieldViews[i] != null) _shieldViews[i].gameObject.SetActive(h != null && h.Hp > 0 && h.Shield > 0);
if (_projectiles[i] != null) _projectiles[i].gameObject.SetActive(h != null && h.Hp > 0 && (_battle.Config?.pets[h.Pet].range ?? (h.Pet == 0 ? 2 : 8)) > 2 && h.Pulse > 0);
if (h == null) continue;
_heroViews[i].localPosition = new Vector3(PrototypeBattle.LaneX(h.Lane), .04f * Mathf.Sin(_battle.Time * 4 + i), h.Z);
var animator = _heroAnimators[i];
float scale = h.Tier == 2 ? 1.2f : 1;
var crest = _heroViews[i].Find("T2Crest"); if (crest != null) crest.gameObject.SetActive(h.Tier == 2);
_heroViews[i].localScale = Vector3.one * scale * (1 + h.Pulse * .25f);
if (animator != null)
{
// Pulses restart on each attack/heal and skill cast; play the authored Attack instead of squashing the mesh.
animator.speed = _paused || _battle.Finished ? 0 : _speed;
if (h.Pulse > _heroPulse[i] || h.SkillPulse > _heroSkillPulse[i]) animator.SetTrigger("Attack");
_heroPulse[i] = h.Pulse; _heroSkillPulse[i] = h.SkillPulse;
_heroViews[i].localPosition = new Vector3(PrototypeBattle.LaneX(h.Lane), 0, h.Z);
_heroViews[i].localScale = Vector3.one * scale;
}
else
{
_heroViews[i].localPosition = new Vector3(PrototypeBattle.LaneX(h.Lane), .04f * Mathf.Sin(_battle.Time * 4 + i), h.Z);
_heroViews[i].localScale = Vector3.one * scale * (1 + h.Pulse * .25f);
}
if (_projectiles[i] != null) _projectiles[i].localPosition = new Vector3(PrototypeBattle.LaneX(h.Lane), .85f, Mathf.Lerp(h.Z, h.TargetZ, 1 - h.Pulse / .25f));
if (_skillViews[i] != null) { _skillViews[i].localPosition = new Vector3(PrototypeBattle.LaneX(h.SkillTargetLane), .1f, h.SkillTargetZ); _skillViews[i].localScale = new Vector3(1.2f + (.6f - h.SkillPulse) * 2, .08f, 1.2f + (.6f - h.SkillPulse) * 2); }
if (_shieldViews[i] != null) _shieldViews[i].localPosition = new Vector3(PrototypeBattle.LaneX(h.Lane), .1f, h.Z);
@@ -1066,25 +1166,155 @@ namespace HotUpdate.Gameplay
_enemyViews[i].gameObject.SetActive(e != null && (e.Alive || e.Pulse > 0));
if (e == null) continue;
var slowMark = _enemyViews[i].Find("SlowMark"); if (slowMark != null) slowMark.gameObject.SetActive(e.SlowSeconds > 0);
_enemyViews[i].localPosition = new Vector3(PrototypeBattle.LaneX(e.Lane) + Mathf.Sin(i * 1.3f) * .38f, Mathf.Abs(Mathf.Sin(_battle.Time * 6 + i)) * .12f, e.Z);
_enemyViews[i].localScale = Vector3.one * (e.Boss ? 1.55f : e.Type == 3 ? 1.2f : 1) * (e.Alive ? 1 - e.Pulse * .5f : Mathf.Clamp01(e.Pulse * 5));
var types = _enemyTypes[i]; var visual = e.Type < types.Length ? types[e.Type] : null;
foreach (var t in types) if (t != null) t.gameObject.SetActive(t == visual);
if (_enemyModels[i] != e) { _enemyModels[i] = e; _enemyStrikes[i] = e.Strikes; }
float size = e.Boss ? 1.55f : e.Type == 3 ? 1.2f : 1, fade = Mathf.Clamp01(e.Pulse * 5);
float x = PrototypeBattle.LaneX(e.Lane) + Mathf.Sin(i * 1.3f) * .38f;
if (visual != null)
{
// Authored Walk/Idle replace the hop; a strike plays Attack once, hits only squash lightly.
var animator = _enemyAnimators[i][e.Type];
if (animator != null)
{
animator.speed = _paused || _battle.Finished ? 0 : _speed;
animator.SetInteger("Motion", e.Alive && e.Moving ? 1 : 0);
if (e.Strikes > _enemyStrikes[i]) animator.SetTrigger("Attack");
}
_enemyStrikes[i] = e.Strikes;
_enemyViews[i].localPosition = new Vector3(x, 0, e.Z);
_enemyViews[i].localScale = Vector3.one * size * (e.Alive ? 1 - e.Pulse * .25f : fade);
}
else
{
_enemyViews[i].localPosition = new Vector3(x, Mathf.Abs(Mathf.Sin(_battle.Time * 6 + i)) * .12f, e.Z);
_enemyViews[i].localScale = Vector3.one * size * (e.Alive ? 1 - e.Pulse * .5f : fade);
}
}
}
int CounterElement(int enemy)
{
var cycle = _config.elements.cycle;
for (int i = 0; i < cycle.Length; i++)
if (cycle[(i + 1) % cycle.Length] == enemy) return cycle[i];
return 0;
}
void RefreshChallenges()
{
if (_page == null || _page.Go.name != "UI_PrototypeChallenges" || _config.challenges == null) return;
var save = _game.State;
_challenge = Mathf.Clamp(_challenge, 0, _config.challenges.Length - 1);
bool unlocked = save.clearedStages != null && save.clearedStages.Length > 0 && save.clearedStages[0] == 1;
_page.Text("Summary", unlocked ? "通过 1-1 后开放。只上阵对应元素,首次通过获得徽章和碎片。" : "先通过 1-1,再来参加属性试炼。");
for (int i = 0; i < _config.challenges.Length; i++)
{
var challenge = _config.challenges[i];
int owned = 0;
for (int pet = 0; pet < _config.pets.Length; pet++) if (save.stages[pet] > 0 && _config.pets[pet].element == challenge.allyElement) owned++;
string state = !unlocked ? "通过 1-1 后开放"
: owned == 0 ? "还没有" + PrototypeConfig.ElementName(challenge.allyElement) + "元素伙伴"
: save.challengeBadges != null && save.challengeBadges[i] == 1 ? "已获" + challenge.badge + (save.challengeBestMs[i] > 0 ? " · " + (save.challengeBestMs[i] / 1000f).ToString("0.0") + "秒" : "")
: "可挑战";
_page.Text("Challenge" + i, (_challenge == i ? "✓ " : "") + challenge.code + " · " + challenge.name);
_page.Text("ChallengeState" + i, state);
}
var selected = _config.challenges[_challenge];
var names = new List<string>();
for (int pet = 0; pet < _config.pets.Length && names.Count < selected.lanes * 2; pet++)
if (save.stages[pet] > 0 && _config.pets[pet].element == selected.allyElement) names.Add(_config.pets[pet].name);
string squad = names.Count == 0 ? selected.obtain : "上场:" + string.Join("、", names);
string record = save.challengeBestMs != null && save.challengeBestMs[_challenge] > 0 ? "个人纪录 " + (save.challengeBestMs[_challenge] / 1000f).ToString("0.0") + " 秒" : "尚无纪录";
_page.Text("Detail", selected.hint + "\n敌人:" + PrototypeConfig.ElementName(selected.element) + "\n" + squad + "\n首次奖励:" + _config.pets[selected.shardsPet].name + "碎片 " + selected.shards + " · " + selected.badge + "\n" + record);
_page.Button("Start").interactable = !Blocked && unlocked && names.Count > 0;
_page.Text("Start", names.Count > 0 ? "开始试炼" : "伙伴不足");
}
static readonly string[] RankTabs = { "主线", "火", "草", "岩", "电", "水" };
static readonly string[] RankTitles = { "主线进度", "火试炼", "草试炼", "岩试炼", "电试炼", "水试炼" };
static string RankScore(int board, int score) => board == 0 ? "通关 " + score : (score / 1000f).ToString("0.0") + "秒";
IEnumerator LoadRank(int board)
{
_rankBoard = board; _rank = null; RefreshRank();
yield return Execute("rank.list", r => { _rank = r.rank; RefreshRank(); }, stageId: board);
}
void RefreshRank()
{
if (_page == null || _page.Go.name != "UI_PrototypeRank") return;
for (int i = 0; i < RankTabs.Length; i++) _page.Text("RankTab" + i, (_rankBoard == i ? "✓ " : "") + RankTabs[i]);
var board = _rank != null && _rank.board == _rankBoard ? _rank : null;
var rows = board?.entries ?? new RankEntry[0];
if (board == null) _page.Text("Summary", "正在读取排行…");
else
{
string mine = board.selfPlace > 0 ? "我的排名:第 " + board.selfPlace + " 名 · " + RankScore(board.board, board.selfScore) + (board.selfAt > 0 ? "" : " · 时间未记录") : "我的排名:尚未上榜";
_page.Text("Summary", RankTitles[board.board] + " · 前 20 名 · 共 " + board.total + " 人上榜\n" + mine);
}
_page.Node("Txt_RankEmpty").gameObject.SetActive(board != null && board.total == 0);
for (int i = 0; i < 20; i++)
{
bool show = i < rows.Length;
_page.Node("RankRow" + i).gameObject.SetActive(show);
if (!show) continue;
var row = rows[i];
_page.Text("RankPlace" + i, row.place.ToString());
_page.Text("RankName" + i, (row.pid == _boundProfile ? "你 · " : "") + row.name);
_page.Text("RankScore" + i, RankScore(_rankBoard, row.score));
}
}
IEnumerator StartChallenge()
{
if (Blocked || _config.challenges == null) yield break;
int id = _challenge + 1;
yield return Execute("challenge.begin", stageId: id);
if (_game == null || string.IsNullOrEmpty(_game.State.pendingBattle)) yield break;
yield return StartBattle();
}
string FailureAdvice(StageConfig stage)
{
var notes = new List<string>();
var save = _game.State;
if (stage.elementsEnabled && stage.element != 0)
{
int counter = CounterElement(stage.element);
var owned = _config.pets.Where(p => p.element == counter && p.id < save.stages.Length && save.stages[p.id] > 0).ToArray();
string element = PrototypeConfig.ElementName(counter), foe = PrototypeConfig.ElementName(stage.element);
if (owned.Length == 0) notes.Add("还没有" + element + "伙伴," + element + "克制" + foe);
else if (owned.All(p => Array.IndexOf(save.formation, p.id) < 0)) notes.Add("上阵" + string.Join("或", owned.Select(p => p.name)) + "," + element + "克制" + foe);
}
if (save.level < stage.recommendedLevel) notes.Add("当前训练 Lv." + save.level + ",建议 Lv." + stage.recommendedLevel);
int lanes = _config.Lanes(stage.id);
var empty = Enumerable.Range(0, lanes).Where(lane => save.formation[lane * 2] < 0 && save.formation[lane * 2 + 1] < 0).Select(LaneName).ToArray();
if (empty.Length > 0) notes.Add(string.Join("、", empty) + "还没有伙伴");
return notes.Count == 0 ? "可调整前后排后免费重试" : string.Join(";", notes);
}
void ShowBattleResult(PrototypeResult result)
{
if (_page == null || _page.Go.name != "UI_PrototypeBattle") return;
_settled = true;
if (result.won) UiSfx.Play(result.firstClear ? UiSound.Claim : UiSound.Confirm);
_page.Node("Result").gameObject.SetActive(true);
_page.Text("ResultTitle", result.won ? "守护成功!" : "防守结束");
int stageId = result.stageId > 0 ? result.stageId : 1;
var stage = _battle?.Stage ?? _config.Stage(stageId);
_page.Text("ResultInfo", stage.code + " · " + stage.name + "\n" + (result.won ? "剩余耐久 " + result.durability : stage.hint) + "\n" + (result.firstClear ? "首通:" + RewardText(result.reward ?? stage.reward) : result.won ? "首通奖励已领取 · 本次为重复挑战" : "本次未获得通关奖励"));
if (result.challengeId > 0)
{
var challenge = _config.challenges[result.challengeId - 1];
string record = result.bestMs > 0 ? "纪录 " + (result.bestMs / 1000f).ToString("0.0") + " 秒" : "尚无纪录";
string prize = result.firstClear ? "首次通过:" + _config.pets[challenge.shardsPet].name + "碎片 +" + result.shards + ",获得" + challenge.badge
: result.won ? (result.improved ? "刷新了个人纪录。" : "未超过个人纪录。") + "徽章已领取"
: "本次未获得徽章。可换同元素伙伴或提高训练等级后再试";
_page.Text("ResultInfo", challenge.code + " · " + challenge.name + "\n" + (result.won ? "剩余耐久 " + result.durability : challenge.hint) + "\n" + prize + "\n" + record);
_page.Button("NextStage").gameObject.SetActive(false);
}
else
{
int stageId = result.stageId > 0 ? result.stageId : 1;
var stage = _battle?.Stage ?? _config.Stage(stageId);
_page.Text("ResultInfo", stage.code + " · " + stage.name + "\n" + (result.won ? "剩余耐久 " + result.durability : stage.hint) + "\n" + (result.firstClear ? "首通:" + RewardText(result.reward ?? stage.reward) : result.won ? "首通奖励已领取 · 本次为重复挑战" : "本次未获得通关奖励") + (result.won ? "" : "\n建议:" + FailureAdvice(stage)));
_page.Button("NextStage").gameObject.SetActive(result.won && stageId < _config.stages.Length);
_page.Button("NextStage").interactable = !Blocked && stageId < _game.State.UnlockedStage;
_page.Text("NextStage", stageId < _config.stages.Length ? "下一关 " + _config.Stage(stageId + 1).code : "本轮关卡已完成");
}
string report = result.stats == null || result.stats.Length == 0 ? "旧版挑战:保留原规则与奖励" : string.Join("\n", result.stats.Select(h => _config.pets[h.pet].name + " 伤害" + Mathf.RoundToInt(h.damage) + " 治疗" + Mathf.RoundToInt(h.healing) + " 护盾" + Mathf.RoundToInt(h.absorbed) + " 技能" + h.skills));
if (!result.won && result.leaks != null && result.leaks.Sum() > 0) report += "\n" + string.Join(" · ", Enumerable.Range(0, 3).Where(l => result.leaks[l] > 0).Select(l => LaneName(l) + "突破" + result.leaks[l] + "次")) + ",请调整对应路线";
_page.Text("BattleReport", report);
_page.Button("NextStage").gameObject.SetActive(result.won && stageId < _config.stages.Length);
_page.Button("NextStage").interactable = !Blocked && stageId < _game.State.UnlockedStage;
_page.Text("NextStage", stageId < _config.stages.Length ? "下一关 " + _config.Stage(stageId + 1).code : "本轮关卡已完成");
}
void FinishBattle(bool abandon = false)
{
@@ -1139,10 +1369,12 @@ namespace HotUpdate.Gameplay
}
if (_battle != null && _page != null && _page.Go.name == "UI_PrototypeBattle")
{
_enemyFeedback?.SetPlayback(_paused, _speed);
if (!_paused && !_battle.Finished)
{
_battleAccumulator += Time.deltaTime * _speed;
while (_battleAccumulator >= PrototypeBattle.Step) { _battleAccumulator -= PrototypeBattle.Step; _battle.Tick(); }
while (_battleAccumulator >= PrototypeBattle.Step) { _battleAccumulator -= PrototypeBattle.Step; _battle.Tick();
while (_battle.TryDequeueCue(out var cue)) _enemyFeedback?.Play(cue); }
}
RefreshBattle();
if (_battle.Finished) FinishBattle();
@@ -33,6 +33,9 @@ namespace HotUpdate.Gameplay
public int[] shopBought;
public int[] chests;
public int chestDailyClaimed;
public int[] challengeBadges, challengeBestMs;
public long storyRankAt;
public long[] challengeRankAt;
public PrototypeMail[] mails;
public int selectedStage = 1;
public int[] clearedStages = new int[5];
@@ -40,6 +43,19 @@ namespace HotUpdate.Gameplay
{
get { int id = 1; while (id < clearedStages.Length && clearedStages[id - 1] == 1) id++; return id; }
}
/// <summary>Presentation-only save for replaying a server battle; schema and filler fields follow the roster width so it passes Valid.</summary>
public static PrototypeSave BattleSnapshot(int level, int[] stages, int[] formation)
{
int schema = stages.Length >= 10 ? 9 : stages.Length == 5 ? 3 : 1;
var save = new PrototypeSave { schema = schema, level = level, stages = stages, shards = new int[stages.Length], formation = formation };
if (schema >= 9) save.clearedStages = new int[20];
if (schema >= 4) { save.campLayout = new[] { -1, -1, -1 }; save.decorations = new int[3]; save.restPet = Math.Max(0, Array.FindIndex(stages, t => t > 0)); }
if (schema >= 5) { save.dailyDay = 1; save.signinDays = 1; save.dailyProgress = new int[3]; save.dailyClaimed = new int[3]; save.signinClaimed = new int[7]; }
if (schema >= 6) save.shopBought = new int[3];
if (schema >= 7) save.mails = new PrototypeMail[0];
if (schema >= 8) save.chests = new int[3];
return save;
}
}
public sealed class PrototypePet
@@ -123,7 +139,7 @@ namespace HotUpdate.Gameplay
}
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;
if (s == null || s.schema < 1 || s.schema > 11 || 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)
{
@@ -166,6 +182,19 @@ namespace HotUpdate.Gameplay
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;
}
if (s.schema >= 10)
{
if (s.challengeBadges?.Length != 5 || s.challengeBestMs?.Length != 5) return false;
for (int i = 0; i < 5; i++) if ((s.challengeBadges[i] != 0 && s.challengeBadges[i] != 1) || s.challengeBestMs[i] < 0 || s.challengeBestMs[i] > 120000) return false;
}
if (s.schema >= 11)
{
if (s.storyRankAt < 0 || s.challengeRankAt == null || s.challengeRankAt.Length != 5) return false;
bool cleared = false;
if (s.clearedStages != null) foreach (int stage in s.clearedStages) if (stage == 1) cleared = true;
if (!cleared && s.storyRankAt != 0) return false;
for (int i = 0; i < 5; i++) if (s.challengeRankAt[i] < 0 || (s.challengeBestMs[i] == 0 && s.challengeRankAt[i] != 0)) return false;
}
return true;
}
void Commit(Action<PrototypeSave> change)
@@ -0,0 +1,49 @@
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>Order matches the clips serialized on the UiSfx prefab (Ui_Click … Ui_Notify).</summary>
public enum UiSound { Click, Confirm, Back, Open, Close, Tab, Claim, Deny, Toggle, Notify }
/// <summary>One-shot UI feedback from the authored UiSfx prefab; obeys the SFX setting, independent of music.</summary>
public sealed class UiSfx : MonoBehaviour
{
public const string PrefabName = "UiSfx";
public const string PrefabFolder = "Assets/Bundles/Sound/Prefab";
// Ignore the same sound re-requested within this window (e.g. a refresh firing twice in one frame).
const float RepeatGuard = .06f;
[SerializeField] AudioSource _source;
[SerializeField] AudioClip[] _clips;
static UiSfx _instance;
UiSound _last;
float _lastAt = -1;
public AudioSource Source => _source;
public AudioClip Clip(UiSound sound) => _clips != null && (int)sound < _clips.Length ? _clips[(int)sound] : null;
void OnEnable() { _instance = this; }
void OnDisable() { if (_instance == this) _instance = null; }
public static void Play(UiSound sound)
{
var player = _instance;
if (player == null || player._source == null || !PrototypeClientPrefs.Sfx) return;
var clip = player.Clip(sound);
if (clip == null) return;
float now = Time.unscaledTime;
if (sound == player._last && now - player._lastAt < RepeatGuard) return;
player._last = sound; player._lastAt = now;
player._source.PlayOneShot(clip);
}
/// <summary>Button names from the UI JSON decide the feedback; anything unlisted is a plain click.</summary>
public static UiSound ForButton(string name)
{
if (name == "Back" || name == "Home" || name == "Close") return UiSound.Close;
if (name == "CancelPreview" || name == "RemovePet" || name == "Abandon") return UiSound.Back;
if (name.Contains("Tab") || name == "MailPrev" || name == "MailNext") return UiSound.Tab;
if (name.StartsWith("Toggle") || name == "CycleQuality") return UiSound.Toggle;
return UiSound.Click;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6b56850466d2d934694c8caccad0a737
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,177 @@
using System.Collections;
using HotUpdate.Gameplay;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace HotUpdate.UI
{
/// <summary>Walks a placeholder explorer over the camp painting and speaks only after a tap.</summary>
public sealed class HomeExplorerPresenter : MonoBehaviour
{
const int Layer = 31;
static readonly Color Key = new Color(0.07f, 0.02f, 0.09f, 1f);
static readonly string[] Lines =
{
"喷泉今天的水声很好听。",
"芽芽他们在营地里等你。",
"想再去弹珠台转转吗?",
"这条石路是我每天散步的地方。",
"花开得正好,要不要一起看看?",
"下一关的路上好像有新朋友。",
"训练之后,大家都会更有精神。",
"我在这儿看着营地,你去冒险吧。"
};
UiPrefabLoader _loader;
GameObject _stage;
Camera _camera;
Transform _actor;
RectTransform _button;
RectTransform _bubble;
TextMeshProUGUI _line;
float _hideAt;
int _last = -1;
bool _hooked;
Vector3 _wander;
public bool Ready => _actor != null;
public bool Talking => _bubble != null && _bubble.gameObject.activeSelf;
public string Line => _line != null ? _line.text : "";
void OnEnable()
{
if (_camera != null) _camera.enabled = true;
else StartCoroutine(Boot());
}
void OnDisable()
{
if (_camera != null) _camera.enabled = false;
if (_bubble != null) _bubble.gameObject.SetActive(false);
}
void OnDestroy()
{
if (_button != null) _button.GetComponent<Button>().onClick.RemoveListener(Speak);
if (_stage != null) Destroy(_stage);
if (_camera != null && _camera.targetTexture != null)
{
_camera.targetTexture.Release();
Destroy(_camera.targetTexture);
}
_loader?.Dispose();
}
IEnumerator Boot()
{
var root = transform.parent;
_button = root.Find("Btn_Explorer") as RectTransform;
_bubble = root.Find("Panel_ExplorerTalk") as RectTransform;
_line = root.Find("Panel_ExplorerTalk/Txt_ExplorerTalk").GetComponent<TextMeshProUGUI>();
_bubble.gameObject.SetActive(false);
yield return null;
GameObject prefab = null;
_loader = new UiPrefabLoader(null, "Assets/Bundles/Characters/Explorer");
yield return _loader.LoadAsync("ExplorerSimple", loaded => prefab = loaded);
if (prefab == null || _stage != null) yield break;
_stage = new GameObject("HomeExplorerStage");
_stage.transform.position = new Vector3(40f, 0f, 0f);
_actor = Instantiate(prefab, _stage.transform).transform;
_actor.localPosition = Vector3.zero;
_actor.localRotation = Quaternion.Euler(0f, 180f, 0f);
SetLayer(_actor);
var lightGo = new GameObject("Light");
lightGo.transform.SetParent(_stage.transform, false);
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.15f;
light.cullingMask = 1 << Layer;
light.transform.rotation = Quaternion.Euler(38f, -28f, 0f);
SetLayer(lightGo.transform);
var camGo = new GameObject("Camera");
camGo.transform.SetParent(_stage.transform, false);
_camera = camGo.AddComponent<Camera>();
_camera.clearFlags = CameraClearFlags.SolidColor;
_camera.backgroundColor = Key;
_camera.cullingMask = 1 << Layer;
_camera.orthographic = true;
_camera.orthographicSize = 1.45f;
_camera.nearClipPlane = 0.05f;
_camera.farClipPlane = 30f;
_camera.allowHDR = false;
_camera.depth = -50;
_camera.transform.localPosition = new Vector3(0.15f, 1.7f, -3.6f);
_camera.transform.LookAt(_stage.transform.TransformPoint(0f, 0.7f, 0f));
var rt = new RenderTexture(512, 512, 16, RenderTextureFormat.ARGB32);
rt.Create();
_camera.targetTexture = rt;
var raw = gameObject.GetComponent<RawImage>() ?? gameObject.AddComponent<RawImage>();
raw.texture = rt;
raw.raycastTarget = false;
raw.color = Color.white;
var shader = Shader.Find("AIGame/HomeExplorerCutout");
if (shader != null)
{
var material = new Material(shader);
material.SetColor("_Key", Key);
raw.material = material;
}
_bubble.gameObject.SetActive(false);
if (!_hooked && _button != null)
{
_button.GetComponent<Button>().onClick.AddListener(Speak);
_hooked = true;
}
}
void Update()
{
if (_actor == null) return;
float t = Time.time * 0.45f;
Vector3 next = new Vector3(Mathf.Sin(t) * 1.05f, 0f, Mathf.Sin(t * 0.5f) * 0.28f);
Vector3 dir = next - _wander;
_wander = next;
_actor.localPosition = next;
if (dir.sqrMagnitude > 0.000001f)
_actor.localRotation = Quaternion.LookRotation(new Vector3(dir.x, 0f, dir.z)) * Quaternion.Euler(0f, 180f, 0f);
}
void LateUpdate()
{
if (_actor == null || _camera == null || _button == null) return;
var stage = (RectTransform)transform;
Vector3 view = _camera.WorldToViewportPoint(_actor.position + Vector3.up * 0.75f);
if (view.z < 0f) return;
Vector3 world = stage.TransformPoint(new Vector3((view.x - 0.5f) * stage.rect.width, (view.y - 0.5f) * stage.rect.height, 0f));
_button.position = world;
if (_bubble.gameObject.activeSelf)
{
_bubble.position = _button.position + new Vector3(0f, 150f * _button.lossyScale.y, 0f);
if (Time.unscaledTime >= _hideAt) _bubble.gameObject.SetActive(false);
}
}
public void Speak()
{
if (_line == null || _bubble == null) return;
int index = Random.Range(0, Lines.Length);
if (Lines.Length > 1 && index == _last) index = (index + 1) % Lines.Length;
_last = index;
_line.text = Lines[index];
_bubble.gameObject.SetActive(true);
_hideAt = Time.unscaledTime + 3.5f;
}
static void SetLayer(Transform root)
{
root.gameObject.layer = Layer;
foreach (Transform child in root) SetLayer(child);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 27a0e40b2f8c1e049adc17487bfe0501
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -65,7 +65,7 @@ namespace HotUpdate.UI
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_Task", GuideTask(state));
SetText("Txt_AdventureHint", state.cleared ? "首通已完成 · 可重复挑战" : "首通另获糖果 40 · 不消耗体力");
if (config != null && state.schema >= 2)
{
@@ -100,6 +100,16 @@ namespace HotUpdate.UI
}
}
public static string GuideTask(Gameplay.PrototypeSave state)
{
if (state.goalClaimed) return "教学目标已完成";
if (state.cleared) return "领取首通目标奖励";
if (state.shots == 0) return "发射第一枚弹珠";
if (!state.captureTutorialDone && state.tickets > 0) return "用捕捉券获得芽芽";
if (state.level < 2) return "用糖果做一次共享训练";
return "布阵并完成教学关 1-1";
}
void SetText(string name, string value)
{
var label = FindTmp(name);
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9e9033ec968970c4e87a81c0c72f8c6c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
Shader "AIGame/EnemyCombatParticle"
{
Properties { _MainTex ("Soft particle", 2D) = "white" {} }
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" }
Pass
{
Blend SrcAlpha One
Cull Off ZWrite Off
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
struct appdata { float4 vertex:POSITION; float2 uv:TEXCOORD0; float4 color:COLOR; };
struct v2f { float4 vertex:SV_POSITION; float2 uv:TEXCOORD0; float4 color:COLOR; };
v2f vert(appdata v) { v2f o; o.vertex=UnityObjectToClipPos(v.vertex); o.uv=v.uv; o.color=v.color; return o; }
half4 frag(v2f i):SV_Target { return tex2D(_MainTex,i.uv)*i.color; }
ENDHLSL
}
}
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 2a89d799ffea50e4ab98f202a1e2433f
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,35 @@
Shader "AIGame/HomeExplorerCutout"
{
Properties
{
_MainTex ("Tex", 2D) = "white" {}
_Key ("Key", Color) = (0.07, 0.02, 0.09, 1)
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _Key;
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };
struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; };
v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; }
fixed4 frag(v2f i) : SV_Target
{
fixed4 c = tex2D(_MainTex, i.uv);
if (distance(c.rgb, _Key.rgb) < 0.12) discard;
c.a = 1;
return c;
}
ENDCG
}
}
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a10b9d049369c454da52258d8d8ba24f
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant: