Add rank, friends, fishing, and camp treasure with T2 studio models and skill presentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-21 23:01:31 +08:00
co-authored by Cursor
parent 89e0fa246f
commit 079313fb06
677 changed files with 395371 additions and 9692 deletions
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>Authored pool and animation phase adapter. Accepts resolved events only; cannot change combat data.</summary>
public sealed class BattleSkillPresentation : MonoBehaviour
{
[Serializable] public sealed class Pool { public ParticleSystem[] instances; [NonSerialized] public int next; }
[Serializable] public sealed class Profile
{
public int pet,chargeEffect,projectileEffect,hitEffect,abilityEffect,releaseEffect;
public int chargeSound,launchSound,hitSound,abilitySound;
}
[SerializeField] Pool[] _effects;
[SerializeField] AudioClip[] _clips;
[SerializeField] AudioSource[] _voices;
[SerializeField] float[] _petImpacts, _enemyImpacts;
[SerializeField] Profile[] _profiles;
[SerializeField] bool[] _groundEffects;
[SerializeField] float[] _effectLifetimes;
sealed class Lease { public ParticleSystem ps; public int generation; public LineRenderer[] lines; }
sealed class Shot { public Lease lease; public int target; public Vector3 start; public float born,arrival; }
sealed class Charge { public Lease lease; public int owner; }
readonly Dictionary<ParticleSystem,int> _generations=new Dictionary<ParticleSystem,int>();
readonly Dictionary<int,Charge> _charges=new Dictionary<int,Charge>();
readonly Dictionary<int,Lease> _shields=new Dictionary<int,Lease>();
readonly List<Shot> _shots=new List<Shot>();
readonly List<(Lease lease,float until)> _flashes=new List<(Lease,float)>();
readonly Dictionary<int,int> _castTargets=new Dictionary<int,int>();
readonly HashSet<(int cast,int sound)> _castSounds=new HashSet<(int,int)>();
int _generation,_voice;
int[] _voiceCasts;
float _speed=1;
bool _paused;
public int PlayedSounds {get;private set;}
public int PlayedEffects {get;private set;}
public int ActiveShots=>_shots.Count;
public int ActiveCharges=>_charges.Count;
public int ActiveShields=>_shields.Count;
public int EffectKinds=>_effects?.Length??0;
public int ProfileCount=>_profiles?.Length??0;
public int ActiveBeams=>_flashes.Count;
public int[] EffectPlays {get;private set;}
public int[] SoundPlays {get;private set;}
void OnEnable(){PrototypeClientPrefs.AudioPreferencesChanged+=AudioChanged;ResetPlayback();}
void OnDisable(){PrototypeClientPrefs.AudioPreferencesChanged-=AudioChanged;ResetPlayback();}
void AudioChanged(){if(!PrototypeClientPrefs.Sfx)foreach(var voice in _voices)voice.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 voice in _voices)voice.Stop();
_charges.Clear();_shields.Clear();_shots.Clear();_flashes.Clear();_castTargets.Clear();_castSounds.Clear();_generations.Clear();
if(_effects!=null)foreach(var pool in _effects)foreach(var ps in pool.instances)foreach(var line in ps.GetComponentsInChildren<LineRenderer>())line.enabled=false;
_voiceCasts=new int[_voices?.Length??0];EffectPlays=new int[_effects?.Length??0];SoundPlays=new int[_clips?.Length??0];_paused=false;_speed=1;PlayedSounds=PlayedEffects=0;
}
bool Current(Lease lease)=>lease!=null&&_generations.TryGetValue(lease.ps,out int n)&&n==lease.generation;
void Stop(Lease lease){if(Current(lease)){lease.ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);foreach(var line in lease.lines)line.enabled=false;_generations.Remove(lease.ps);}}
Lease Effect(int kind,Vector3 position,Color? color=null)
{
var pool=_effects[kind];var ps=pool.instances[pool.next++%pool.instances.Length];ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);ps.transform.position=position;ps.transform.rotation=Quaternion.identity;
foreach(var child in ps.GetComponentsInChildren<ParticleSystem>()){var main=child.main;main.simulationSpeed=_speed;if(color.HasValue)main.startColor=color.Value;}
var lease=new Lease{ps=ps,generation=++_generation,lines=ps.GetComponentsInChildren<LineRenderer>()};foreach(var line in lease.lines)line.enabled=false;_generations[ps]=lease.generation;ps.Play(true);foreach(var child in ps.GetComponentsInChildren<ParticleSystem>())if(child.main.loop)child.Emit(1);PlayedEffects++;EffectPlays[kind]++;return lease;
}
void Sound(int id,int cast=0)
{
if(id<0||_paused||!PrototypeClientPrefs.Sfx||cast>0&&!_castSounds.Add((cast,id)))return;int index=_voice++%_voices.Length;var voice=_voices[index];voice.Stop();voice.clip=_clips[id];voice.pitch=1;voice.volume=.3f/_voices.Length;_voiceCasts[index]=cast;voice.Play();PlayedSounds++;SoundPlays[id]++;
}
void EndCharge(int cast)
{
if(_charges.TryGetValue(cast,out var charge)){Stop(charge.lease);_charges.Remove(cast);}
for(int i=0;i<_voices.Length;i++)if(_voiceCasts[i]==cast&&_voices[i].clip==_clips[0])_voices[i].Stop();
}
static Color Element(int element)=>element==1?new Color(1,.32f,.04f):element==2?new Color(.4f,1,.28f):element==3?new Color(.8f,.7f,.4f):element==4?new Color(1,.9f,.2f):element==5?new Color(.3f,.85f,1):Color.white;
Lease Burst(int kind,Vector3 position,float time,Vector3? source=null)
{
if(_groundEffects[kind])position.y=transform.position.y+.06f;
var lease=Effect(kind,position);
foreach(var line in lease.lines)
{
Vector3 start=source??position,end=position;
Vector3 origin=lease.ps.transform.InverseTransformPoint(start),delta=lease.ps.transform.InverseTransformVector(end-start);Vector3 side=Vector3.Cross(delta.normalized,Vector3.up);if(side.sqrMagnitude<.1f)side=Vector3.right;
for(int i=0;i<line.positionCount;i++){float t=i/(float)(line.positionCount-1);line.SetPosition(i,origin+delta*t+side*(i==0||i==line.positionCount-1?0:((i%2==0?1:-1)*.10f)));}line.enabled=true;
}
_flashes.Add((lease,time+_effectLifetimes[kind]));return lease;
}
public void Play(CombatEvent e,float time,Func<int,Vector3> point,PrototypeConfig config)
{
bool fresh=time-e.tick*.05f<.15f;bool pet=e.source>=0&&e.source<config.pets.Length;int element=pet?config.pets[e.source].element:0;var profile=pet?_profiles[e.source]:null;bool ability=e.skill!=null&&e.skill.EndsWith(".ability",StringComparison.Ordinal);
if(e.kind=="cast_start")
{
_castTargets[e.cast]=e.target;
if(pet&&profile.chargeEffect>=0&&fresh){_charges[e.cast]=new Charge{owner=e.source,lease=Effect(profile.chargeEffect,point(e.source))};Sound(profile.chargeSound,e.cast);}
}
else if(e.kind=="release")
{
EndCharge(e.cast);if(pet&&fresh){Sound(profile.launchSound,e.cast);if(ability&&profile.releaseEffect>=0){var lease=Burst(profile.releaseEffect,point(e.source),time);if(_castTargets.TryGetValue(e.cast,out int target)){Vector3 direction=point(target)-point(e.source);if(direction.sqrMagnitude>.001f)lease.ps.transform.rotation=Quaternion.LookRotation(direction);}}}
}
else if(e.kind=="cancel"||e.kind=="cast_end"){EndCharge(e.cast);_castTargets.Remove(e.cast);}
else if(e.kind=="projectile"&&pet&&e.arrival*.05f>time)_shots.Add(new Shot{lease=Effect(profile.projectileEffect,point(e.source),Element(element)),target=e.target,start=point(e.source),born=e.tick*.05f,arrival=e.arrival*.05f});
else if(e.kind=="hit")
{
if(pet&&fresh){Burst(profile.hitEffect,point(e.target),time,point(e.source));Sound(profile.hitSound,e.cast);}
if(e.shield<=0&&_shields.TryGetValue(e.target,out var old)){Stop(old);_shields.Remove(e.target);}
}
else if(e.kind=="shield")
{
if(_shields.TryGetValue(e.target,out var old))Stop(old);
if(pet&&e.expires*.05f>time){_shields[e.target]=Effect(profile.abilityEffect,point(e.target));if(fresh)Sound(profile.abilitySound,e.cast);}
}
else if((e.kind=="heal"||e.kind=="slow")&&pet&&fresh){Burst(profile.abilityEffect,point(e.target),time);Sound(profile.abilitySound,e.cast);}
else if(e.kind=="shield_expired"||e.kind=="death"||e.kind=="exit")
{
if(_shields.TryGetValue(e.target,out var old)){Stop(old);_shields.Remove(e.target);}
foreach(int cast in _charges.Where(x=>x.Value.owner==e.target).Select(x=>x.Key).ToArray())EndCharge(cast);
}
else if(e.kind=="battle_end")ClearLeases();
}
void ClearLeases()
{
foreach(var pool in _effects)foreach(var ps in pool.instances)ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);
foreach(var voice in _voices)voice.Stop();foreach(var pool in _effects)foreach(var ps in pool.instances)foreach(var line in ps.GetComponentsInChildren<LineRenderer>())line.enabled=false;_charges.Clear();_shields.Clear();_shots.Clear();_flashes.Clear();_castTargets.Clear();_castSounds.Clear();_generations.Clear();
}
public void Render(float time,Func<int,Vector3> point,Func<int,bool> alive)
{
for(int i=_flashes.Count-1;i>=0;i--)if(time>=_flashes[i].until||!Current(_flashes[i].lease)){Stop(_flashes[i].lease);_flashes.RemoveAt(i);}
foreach(var charge in _charges.Values)if(Current(charge.lease))charge.lease.ps.transform.position=point(charge.owner);
foreach(var shield in _shields)if(Current(shield.Value))shield.Value.ps.transform.position=point(shield.Key);
for(int i=_shots.Count-1;i>=0;i--){var shot=_shots[i];if(time>=shot.arrival||!alive(shot.target)||!Current(shot.lease)){Stop(shot.lease);_shots.RemoveAt(i);continue;}float t=Mathf.InverseLerp(shot.born,shot.arrival,time);shot.lease.ps.transform.position=Vector3.Lerp(shot.start,point(shot.target),t)+Vector3.up*Mathf.Sin(t*Mathf.PI);}
}
public void SetPlayback(bool paused,float speed)
{
if(_paused==paused&&_speed==speed)return;
foreach(var pool in _effects)foreach(var ps in pool.instances){foreach(var child in ps.GetComponentsInChildren<ParticleSystem>()){var main=child.main;main.simulationSpeed=speed;}if(paused&&!_paused)ps.Pause(true);else if(!paused&&_paused&&ps.IsAlive(true))ps.Play(true);}
if(paused!=_paused)foreach(var voice in _voices){if(paused)voice.Pause();else voice.UnPause();}_paused=paused;_speed=speed;
}
public void Animate(Animator animator,bool pet,int art,float time,int start,int release,int end,bool moving)
{
// Dead/unspawned units and unused art variants remain in the authored pool.
// Their next visible frame is sampled from the absolute combat clock.
if(animator==null||!animator.isActiveAndEnabled)return;animator.speed=0;animator.ResetTrigger("Attack");float tick=time*20;float[] impacts=pet?_petImpacts:_enemyImpacts;
if(start>=0&&release>start&&tick<end)
{
float impact=impacts[art];float phase=tick<release?Mathf.InverseLerp(start,release,tick)*impact:Mathf.Lerp(impact,1,Mathf.InverseLerp(release,end,tick));animator.Play("Attack",0,Mathf.Clamp(phase,0,.999f));
}
else{animator.SetInteger("Motion",moving?1:0);animator.Play(moving?"Walk":"Idle",0,time/2%1);}
animator.Update(0);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b62df46d8f541a44936c8ece3dd8d24
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
namespace HotUpdate.Gameplay
{
[Serializable] public sealed class CombatUnit
{
public int id, team, art, lane, z, hp, maxHp, shield, tier, slot, slow;
public bool exited, spawned, boss;
}
[Serializable] public sealed class CombatTraceManifest
{
public int schema, protocol, logicVersion, rulesVersion, tickMs, lastTick, eventCount, initialDurability, durability, defeated;
public string kind, traceId, traceHash, gameplayHash, skillsHash;
public float duration;
public bool won;
public CombatUnit[] initial, final;
public int[] leaks;
}
[Serializable] public sealed class CombatEvent
{
public string kind, skill, action, reason;
public int seq, tick, source, target, cast, release, end, arrival, fromZ, toZ;
public int id, team, art, lane, z, hp, maxHp, shield, tier, slot, slow, amount, absorbed, expires, factor, durability, defeated, leaks;
public bool boss, spawned, exited, won;
}
[Serializable] public sealed class CombatPage { public string activityId, traceId, payload, sha256; public int cursor, nextCursor, total; }
[Serializable] public sealed class CombatPageReply { public bool ok; public string error; public CombatPage page; }
[Serializable] public sealed class CombatPageCommand
{
public string requestId, action="battle.events", activityId, traceId;
public int revision, cursor, limit=256;
}
public static class CombatTraceValidation
{
public static string Hash(string text) {using(var hash=SHA256.Create())return BitConverter.ToString(hash.ComputeHash(Encoding.UTF8.GetBytes(text))).Replace("-","").ToLowerInvariant();}
static bool HashId(string value) => value!=null&&value.Length==64&&value.All(c=>c>='0'&&c<='9'||c>='a'&&c<='f');
public static bool Manifest(CombatTraceManifest m)
{
if(m==null||m.schema!=1||m.protocol!=1||m.logicVersion!=1||m.rulesVersion!=3||m.kind!="authoritative_battle"||m.tickMs!=50||m.lastTick<0||m.lastTick>=2400||m.eventCount<1||m.eventCount>50000||m.initialDurability<1||m.initialDurability>1000||!HashId(m.traceId)||!HashId(m.traceHash)||!HashId(m.gameplayHash)||!HashId(m.skillsHash)||m.initial==null||m.final?.Length!=m.initial.Length||m.initial.Length<10||m.initial.Length>15)return false;
var ids=new HashSet<int>();
foreach(var u in m.initial)
if(u==null||!ids.Add(u.id)||u.maxHp<1||u.hp<0||u.hp>u.maxHp||u.lane<0||u.lane>2||u.z< -4000||u.z>11000||(u.team==0?(u.id!=u.art||u.art<0||u.art>9||u.slot<0||u.slot>5||u.tier<1||u.tier>2):(u.team!=1||u.id<100||u.id>108||u.art<0||u.art>4)))return false;
return ids.Count(id=>id>=100)==9&&m.final.All(u=>u!=null&&ids.Contains(u.id))&&m.final.Select(u=>u.id).Distinct().Count()==ids.Count;
}
public static bool Events(CombatTraceManifest m,CombatEvent[] events)
{
if(!Manifest(m)||events==null||events.Length!=m.eventCount)return false;
var ids=new HashSet<int>(m.initial.Select(u=>u.id));int last=-1;
var kinds=new HashSet<string>{"spawn","move","cast_start","release","cast_end","cancel","projectile","hit","heal","shield","slow","shield_expired","slow_expired","death","exit","expired","battle_end"};
for(int i=0;i<events.Length;i++)
{
var e=events[i];if(e==null||e.seq!=i||e.tick<last||e.tick>m.lastTick||!kinds.Contains(e.kind))return false;last=e.tick;
bool source=e.kind=="cast_start"||e.kind=="release"||e.kind=="cast_end"||e.kind=="cancel"||e.kind=="projectile"||e.kind=="hit"||e.kind=="heal"||e.kind=="shield"||e.kind=="slow"||e.kind=="death"||e.kind=="expired";
bool target=e.kind=="spawn"||e.kind=="move"||e.kind=="projectile"||e.kind=="hit"||e.kind=="heal"||e.kind=="shield"||e.kind=="slow"||e.kind=="shield_expired"||e.kind=="slow_expired"||e.kind=="death"||e.kind=="exit"||e.kind=="expired";
if(source&&!ids.Contains(e.source)||target&&!ids.Contains(e.target)||e.kind=="cast_start"&&(e.release<e.tick||e.end<=e.release)||e.kind=="projectile"&&e.arrival<=e.tick)return false;
}
return events[events.Length-1].kind=="battle_end"&&events[events.Length-1].tick==m.lastTick;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b3b473e7e188c2429630125df520834
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -54,16 +54,16 @@ namespace HotUpdate.Gameplay
_paused = paused;
}
public void Play(PrototypeBattle.CombatCue cue)
public void Play(PrototypeBattle.CombatCue cue,bool sound=true)
{
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);
Burst(index, cue.Kind == PrototypeBattle.CueKind.EnemyAttack ? cue.AttackPosition : cue.Position, cue.Direction,sound);
if (cue.Kind == PrototypeBattle.CueKind.EnemyAttack)
Burst(cue.Shield ? 7 : 5, cue.Position, -cue.Direction);
Burst(cue.Shield ? 7 : 5, cue.Position, -cue.Direction,sound);
}
void Burst(int index, Vector3 position, Vector3 direction)
void Burst(int index, Vector3 position, Vector3 direction,bool sound)
{
var pool = _effects[index];
var ps = pool.instances[pool.next++ % pool.instances.Length];
@@ -73,7 +73,7 @@ namespace HotUpdate.Gameplay
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;
if (!sound || !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++;
@@ -146,6 +146,7 @@ namespace HotUpdate.Gameplay
case "Events": title = "小镇活动"; break;
case "Daily": title = "每日签到"; break;
case "Rank": title = "冒险排行"; break;
case "Friends": title = "好友"; break;
case "Mail": title = "小镇邮件"; break;
case "Rewards": title = "宝箱奖励"; break;
case "Bag": title = "背包"; break;
@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using HotUpdate.Net;
using UnityEngine;
@@ -11,7 +12,9 @@ namespace HotUpdate.Gameplay
{
public string requestId, action, activityId, mailId;
public string[] mailIds;
public int revision, aim, pet, slot, stageId, item, day, chest, choice;
public int revision, aim, pet, slot, stageId, item, day, chest, choice, target;
public int combatProtocol;
public int[] tapFish, taps;
}
[Serializable] public sealed class RemoteShot
{
@@ -25,8 +28,8 @@ namespace HotUpdate.Gameplay
r.Path.AddRange(path); return r;
}
}
[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 BattleSnapshot { public int level, stageId, challengeId, rulesVersion; public int[] stages, formation; public bool legacy; public PrototypeConfig config; public string gameplayHash, skillsHash; }
[Serializable] public sealed class RemoteBattle { public string id; public long readyAt; public BattleSnapshot snapshot; public CombatTraceManifest trace; }
[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; }
@@ -47,7 +50,7 @@ namespace HotUpdate.Gameplay
public int item, slot, chargedCoins;
public float duration;
public StageReward reward;
public int chest, choice, remaining, count;
public int chest, choice, remaining, count, fishScore;
public ChestReward chestReward;
public string kind;
public string mailId;
@@ -56,13 +59,28 @@ 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 RankEntry { public long pid; public int score, place, at; public string name; }
[Serializable] public sealed class RankBoard
{
public int board, total, selfPlace, selfScore;
public long selfAt;
public int board, total, selfPlace, selfScore, selfAt;
public RankEntry[] entries;
}
[Serializable] public sealed class FriendCard
{
public long pid;
public string name, relation;
public int[] formation, stages, camp;
}
[Serializable] public sealed class FriendBook
{
public int limit, requestLimit, blockLimit, sendLimit, receiveLimit, sent, received;
public FriendCard[] friends, incoming, outgoing, blocked;
public FriendCard card;
public TreasureFind treasure;
}
[Serializable] public sealed class TreasureFind { public int index; public StageReward reward; }
[Serializable] public sealed class FishSpot { public int at, window, points; }
[Serializable] public sealed class FishSession { public string id; public long startedAt, deadline; public FishSpot[] spots; }
[Serializable] public sealed class PrototypeReply
{
public bool ok, replayed;
@@ -79,6 +97,8 @@ namespace HotUpdate.Gameplay
public DailyStatus daily;
public RewardsStatus rewards;
public RankBoard rank;
public FriendBook friends;
public FishSession fish;
}
/// <summary>One durable request at a time, scoped to endpoint + authenticated player. Never stores tokens.</summary>
@@ -95,8 +115,49 @@ namespace HotUpdate.Gameplay
_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 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, int target = 0, int[] tapFish = null, int[] taps = null)
=> 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, target = target, tapFish = tapFish, taps = taps, combatProtocol = action=="battle.begin"||action=="challenge.begin"?1:0 };
/// <summary>Authenticated bounded reads; never replace or clear the durable command queue.</summary>
public IEnumerator LoadCombat(RemoteBattle battle,Action<CombatEvent[],string> done)
{
var manifest=battle?.trace;
if(!CombatTraceValidation.Manifest(manifest)||battle.snapshot?.rulesVersion!=3||battle.snapshot.gameplayHash!=manifest.gameplayHash||battle.snapshot.skillsHash!=manifest.skillsHash){done(null,"invalid_combat_trace");yield break;}
var events=new List<CombatEvent>(manifest.eventCount);var joined=new StringBuilder("[");int cursor=0;
while(cursor<manifest.eventCount)
{
CombatPageReply reply=null;string error="network_unavailable";
for(int attempt=0;attempt<3;attempt++)
{
var session=_session();if(session==null||session.Pid!=_pid||!session.IsValid){done(null,"unauthorized");yield break;}
var command=new CombatPageCommand{requestId=Guid.NewGuid().ToString("N"),activityId=battle.id,traceId=manifest.traceId,cursor=cursor};
using(var request=new UnityWebRequest(_base+"/game/command","POST"))
{
request.timeout=15;request.downloadHandler=new DownloadHandlerBuffer();request.uploadHandler=new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(command)));request.SetRequestHeader("Content-Type","application/json");request.SetRequestHeader("Authorization","Bearer "+session.Token);
yield return request.SendWebRequest();
try{reply=JsonUtility.FromJson<CombatPageReply>(request.downloadHandler.text);}catch(Exception){reply=null;}
if(request.responseCode==401&&_reauthenticate!=null)
{
_reauthenticate();double deadline=Time.realtimeSinceStartupAsDouble+18;
while(Time.realtimeSinceStartupAsDouble<deadline&&_session()?.Token==session.Token)yield return null;
reply=null;error="unauthorized";
}
else if(request.result==UnityWebRequest.Result.Success&&reply!=null&&reply.ok)break;
else {error=reply?.error??"network_unavailable";reply=null;}
}
if(attempt<2)yield return new WaitForSecondsRealtime(.25f*(attempt+1));
}
if(reply?.page==null){done(null,error);yield break;}
var page=reply.page;
if(page.activityId!=battle.id||page.traceId!=manifest.traceId||page.cursor!=cursor||page.total!=manifest.eventCount||page.nextCursor<=cursor||page.nextCursor>Math.Min(cursor+256,page.total)||page.payload==null||page.payload.Length>262144||CombatTraceValidation.Hash(page.payload)!=page.sha256){done(null,"invalid_combat_trace");yield break;}
CombatEvent[] chunk=null;try{chunk=JsonUtility.FromJson<CombatEventArray>("{\"events\":"+page.payload+"}").events;}catch(Exception){}
if(chunk==null||chunk.Length!=page.nextCursor-cursor||page.payload.Length<2||page.payload[0]!='['||page.payload[page.payload.Length-1]!=']'){done(null,"invalid_combat_trace");yield break;}
if(cursor>0)joined.Append(',');joined.Append(page.payload,1,page.payload.Length-2);events.AddRange(chunk);cursor=page.nextCursor;
}
joined.Append(']');var result=events.ToArray();
if(CombatTraceValidation.Hash(joined.ToString())!=manifest.traceHash||!CombatTraceValidation.Events(manifest,result)){done(null,"invalid_combat_trace");yield break;}
done(result,null);
}
[Serializable] sealed class CombatEventArray { public CombatEvent[] events; }
public IEnumerator Read(Action<PrototypeReply, string> done) => Request(null, done);
public IEnumerator Send(PrototypeCommand command, Action<PrototypeReply, string> done)
{
@@ -154,6 +215,74 @@ namespace HotUpdate.Gameplay
}
return true;
}
static bool ValidFish(PrototypeReply reply, PrototypeCommand request)
{
if (reply.action != request.action) return false;
if (!reply.ok) return true;
// JsonUtility turns a missing fish session into an empty object.
bool live = reply.fish != null && !string.IsNullOrEmpty(reply.fish.id);
if (request.action == "fish.begin")
{
if (!live || reply.fish.id != request.requestId || reply.fish.spots == null || reply.fish.spots.Length != 8) return false;
if (reply.fish.deadline < reply.fish.startedAt || reply.state.pendingFish != request.requestId) return false;
foreach (var spot in reply.fish.spots)
if (spot == null || spot.at < 0 || spot.at > 20000 || spot.window != 900 || (spot.points != 1 && spot.points != 2)) return false;
return true;
}
if (live || !string.IsNullOrEmpty(reply.state.pendingFish)) return false;
var result = reply.result;
if (result == null || result.id != request.activityId || result.fishScore < 0 || result.fishScore > 16 || result.reward == null) return false;
if (request.action == "fish.abandon") return result.fishScore == 0 && result.reward.coins == 0 && result.reward.candy == 0 && result.reward.balls == 0 && result.reward.tickets == 0;
if (request.action != "fish.finish") return false;
int coins = result.fishScore >= 8 ? 30 : result.fishScore >= 5 ? 20 : result.fishScore >= 3 ? 10 : 0;
int candy = result.fishScore >= 8 ? 5 : 0;
return result.reward.coins == coins && result.reward.candy == candy && result.reward.balls == 0 && result.reward.tickets == 0;
}
static bool ValidFriends(PrototypeReply reply, PrototypeCommand request)
{
if (reply.action != request.action) return false;
if (!reply.ok) return reply.friends == null;
var book = reply.friends;
if (book == null || book.limit != 20 || book.requestLimit != 20 || book.blockLimit != 20 || book.sendLimit != 5 || book.receiveLimit != 5) return false;
if (book.sent < 0 || book.sent > book.sendLimit || book.received < 0 || book.received > book.receiveLimit) return false;
if (!FriendRows(book.friends, book.limit, true) || !FriendRows(book.incoming, book.requestLimit, false) || !FriendRows(book.outgoing, book.requestLimit, false) || !FriendRows(book.blocked, book.blockLimit, false)) return false;
// JsonUtility turns a missing card into an empty object. An empty card is the list response.
bool hasCard = book.card != null && book.card.pid > 0;
if (request.action == "friend.list") return !hasCard;
if (!hasCard || book.card.pid != request.target) return false;
bool lineup = book.card.relation == "friend" && request.action != "friend.lookup";
if (request.action == "friend.treasure")
{
if (!FriendShape(book.card, true, true) || book.treasure == null || book.treasure.reward == null || book.treasure.index < 0 || book.treasure.index > 2) return false;
int clears = 0;
if (reply.state.clearedStages != null) foreach (int stage in reply.state.clearedStages) if (stage == 1) clears++;
int coins = clears >= 10 ? 30 : clears >= 4 ? 20 : clears >= 1 ? 10 : 0;
int candy = clears >= 10 ? 5 : 0;
var reward = book.treasure.reward;
return coins > 0 && reward.coins == coins && reward.candy == candy && reward.balls == 0 && reward.tickets == 0 && reply.state.treasurePlayed >= 1 && reply.state.treasurePlayed <= 3;
}
return FriendShape(book.card, lineup, request.action == "friend.visit");
}
static bool FriendRows(FriendCard[] rows, int cap, bool lineup)
{
if (rows == null) return true;
if (rows.Length > cap) return false;
foreach (var row in rows) if (!FriendShape(row, lineup, false)) return false;
return true;
}
static bool FriendShape(FriendCard row, bool lineup, bool camp)
{
if (row == null || row.pid <= 0 || string.IsNullOrEmpty(row.name) || row.name.Length > 64) return false;
if (row.relation != "friend" && row.relation != "incoming" && row.relation != "outgoing" && row.relation != "blocked" && row.relation != "none") return false;
int formation = row.formation == null ? 0 : row.formation.Length;
int stages = row.stages == null ? 0 : row.stages.Length;
int placed = row.camp == null ? 0 : row.camp.Length;
if (lineup) { if (formation != 6 || stages != 10) return false; }
else if (formation != 0 || stages != 0) return false;
if (camp) { if (placed != 3) return false; }
else if (placed != 0) return false;
return true;
}
static bool ValidChestReceipt(PrototypeReply reply, PrototypeCommand request)
{
if (reply.action != request.action) return false;
@@ -203,7 +332,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 > 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)
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 > 14 || (reply.state.schema >= 10 && reply.config.challenges?.Length != 5) || (reply.state.schema >= 12 && reply.config.friends == null) || (reply.state.schema >= 13 && reply.config.fishing == null) || (reply.state.schema >= 14 && reply.config.treasure == null) || (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;
@@ -218,6 +347,10 @@ namespace HotUpdate.Gameplay
{ done(null, "network_unavailable"); yield break; }
if (request.action == "rank.list" && !ValidRank(reply, request))
{ done(null, "network_unavailable"); yield break; }
if (request.action != null && request.action.StartsWith("friend.", StringComparison.Ordinal) && !ValidFriends(reply, request))
{ done(null, "network_unavailable"); yield break; }
if (request.action != null && request.action.StartsWith("fish.", StringComparison.Ordinal) && !ValidFish(reply, request))
{ done(null, "network_unavailable"); yield break; }
}
if (body != null) { PlayerPrefs.DeleteKey(_key); PlayerPrefs.Save(); }
done(reply, null);
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace HotUpdate.Gameplay
{
public sealed partial class PrototypeBattle
{
CombatEvent[] _trace;
CombatTraceManifest _manifest;
int _traceCursor;
readonly Queue<CombatEvent> _skillCues=new Queue<CombatEvent>();
public bool Authoritative => _trace!=null;
public int TraceTick { get; private set; }
public int TraceEventsConsumed => _traceCursor;
public bool TryDequeueSkillCue(out CombatEvent cue){if(_skillCues.Count>0){cue=_skillCues.Dequeue();return true;}cue=null;return false;}
public void LoadTrace(CombatTraceManifest manifest,CombatEvent[] events)
{
if(!CombatTraceValidation.Events(manifest,events))throw new ArgumentException("Invalid authoritative trace");
_trace=events;_manifest=manifest;_traceCursor=0;TraceTick=0;Time=0;Finished=Won=false;Spawned=Defeated=0;Durability=manifest.initialDurability;Enemies.Clear();_cues.Clear();_skillCues.Clear();Array.Clear(Leaks,0,Leaks.Length);
foreach(var u in manifest.initial.Where(u=>u.team==0))
{
var h=Heroes.Single(h=>h.Pet==u.art&&h.Slot==u.slot);h.Hp=u.hp/1000f;h.MaxHp=u.maxHp/1000f;h.Shield=u.shield/1000f;h.Tier=u.tier;h.SkillCasts=0;h.DamageDealt=h.Healing=h.Absorbed=0;h.CastStart=-1;
}
ConsumeAuthoritative();
}
public bool TraceFinalStateMatches
{
get
{
if(!Authoritative||!Finished||Won!=_manifest.won||Durability!=_manifest.durability||Defeated!=_manifest.defeated)return false;
foreach(var u in _manifest.final)
{
if(u.team==0){var h=Heroes.SingleOrDefault(h=>h.Pet==u.art);if(h==null||Mathf.Abs(h.Hp-u.hp/1000f)>.001f||Mathf.Abs(h.Shield-u.shield/1000f)>.001f)return false;}
else if(u.spawned){var e=Enemies.SingleOrDefault(e=>e.Index==u.id-100);if(e==null||Mathf.Abs(e.Z-u.z/1000f)>.0001f||Mathf.Abs(e.Hp-(u.exited?0:u.hp/1000f))>.001f||Mathf.Abs(e.SlowFactor-u.slow/10000f)>.0001f)return false;}
}
return _manifest.leaks!=null&&Leaks.SequenceEqual(_manifest.leaks);
}
}
void TickAuthoritative()
{
if(Finished)return;TraceTick++;Time=TraceTick*Step;
foreach(var h in Heroes){h.Pulse=Mathf.Max(0,h.Pulse-Step);h.SkillPulse=Mathf.Max(0,h.SkillPulse-Step);}
foreach(var e in Enemies){e.Pulse=Mathf.Max(0,e.Pulse-Step);e.SlowSeconds=Mathf.Max(0,(e.SlowExpires-TraceTick)*Step);e.Moving=TraceTick-e.LastMoveTick<2;}
ConsumeAuthoritative();
}
void ConsumeAuthoritative()
{
while(_traceCursor<_trace.Length&&_trace[_traceCursor].tick<=TraceTick)
{
var e=_trace[_traceCursor++];var h=Heroes.FirstOrDefault(x=>x.Pet==e.target);var enemy=Enemies.FirstOrDefault(x=>x.Index==e.target-100);
var source=Heroes.FirstOrDefault(x=>x.Pet==e.source);var attacker=Enemies.FirstOrDefault(x=>x.Index==e.source-100);
if(e.kind=="spawn")
{
var u=_manifest.initial.Single(x=>x.id==e.target);
Enemies.Add(new Enemy{Index=e.target-100,Type=u.art,Lane=u.lane,Boss=u.boss,Hp=e.hp/1000f,MaxHp=e.maxHp/1000f,Z=e.z/1000f});Spawned++;
}
else if(e.kind=="move"){enemy.Z=e.z/1000f;enemy.Moving=true;enemy.LastMoveTick=TraceTick;}
else if(e.kind=="cast_start")
{
if(source!=null){source.CastStart=e.tick;source.CastRelease=e.release;source.CastEnd=e.end;if(e.skill.EndsWith(".ability",StringComparison.Ordinal))source.SkillCasts++;}
else if(attacker!=null){attacker.CastStart=e.tick;attacker.CastRelease=e.release;attacker.CastEnd=e.end;}
}
else if(e.kind=="cancel"||e.kind=="cast_end") {if(source!=null)source.CastStart=-1;else if(attacker!=null)attacker.CastStart=-1;}
else if(e.kind=="hit")
{
if(h!=null){h.Hp=e.hp/1000f;h.Shield=e.shield/1000f;h.Absorbed+=e.absorbed/1000f;if(attacker!=null){attacker.Strikes++;StrikeCue(attacker,h,e.amount==0);}}
else if(enemy!=null){enemy.Hp=e.hp/1000f;enemy.Pulse=.2f;if(source!=null)source.DamageDealt+=e.amount/1000f;HitCue(enemy);}
}
else if(e.kind=="heal"){h.Hp=e.hp/1000f;if(source!=null)source.Healing+=e.amount/1000f;}
else if(e.kind=="shield")h.Shield=e.shield/1000f;
else if(e.kind=="shield_expired")h.Shield=0;
else if(e.kind=="slow"){enemy.SlowFactor=e.factor/10000f;enemy.SlowExpires=e.expires;enemy.SlowSeconds=(e.expires-TraceTick)*Step;}
else if(e.kind=="slow_expired"){enemy.SlowFactor=1;enemy.SlowExpires=0;enemy.SlowSeconds=0;}
else if(e.kind=="death"){if(h!=null){h.Hp=0;h.CastStart=-1;}else if(enemy!=null){enemy.Hp=0;enemy.CastStart=-1;Defeated=e.defeated;}}
else if(e.kind=="exit"){enemy.Hp=0;enemy.Pulse=0;Durability=e.durability;Leaks[e.lane]=e.leaks;}
else if(e.kind=="battle_end"){Finished=true;Won=e.won;Durability=e.durability;Defeated=e.defeated;}
if((e.kind=="heal"||e.kind=="slow")&&source!=null){source.SkillPulse=.6f;source.SkillTargetLane=h?.Lane??enemy?.Lane??source.Lane;source.SkillTargetZ=h?.Z??enemy?.Z??source.Z;}
_skillCues.Enqueue(e);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f346703a0dc47b04ea534740ef4dbd5d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -5,11 +5,12 @@ 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 partial class PrototypeBattle
{
public sealed class Hero
{
public int Pet, Slot, Tier, SkillCasts, SkillTargetLane;
public int CastStart=-1, CastRelease, CastEnd;
public float Hp, MaxHp, Attack, Cooldown, HealCooldown, Pulse, TargetZ;
public float SkillCooldown, SkillPulse, SkillTargetZ, Shield, ShieldSeconds, DamageDealt, Healing, Absorbed;
public int Lane => Slot / 2;
@@ -18,6 +19,7 @@ namespace HotUpdate.Gameplay
public sealed class Enemy
{
public int Index, Type, Lane;
public int CastStart=-1, CastRelease, CastEnd, LastMoveTick=-100, SlowExpires;
public bool Boss;
public float Hp, MaxHp, Z = 11, Cooldown, Pulse;
public bool Alive => Hp > 0;
@@ -80,7 +82,7 @@ namespace HotUpdate.Gameplay
}
public void Tick()
{
if (_config?.battleRules == 2) TickLanes(); else TickClassic();
if (Authoritative) TickAuthoritative(); else if (_config?.battleRules == 2) TickLanes(); else TickClassic();
}
List<Enemy> Targets(Hero hero, float range)
{
@@ -67,6 +67,11 @@ namespace HotUpdate.Gameplay
public int[] enemyLanes;
public StageReward reward;
}
[Serializable] public sealed class FriendConfig { public int limit, requestLimit, blockLimit, sendLimit, receiveLimit; public StageReward gift; }
[Serializable] public sealed class FishTier { public int minScore; public StageReward reward; }
[Serializable] public sealed class FishConfig { public int unlockStage, ballCost, dailyLimit, duration, grace, fishCount, windowMs; public FishTier[] tiers; }
[Serializable] public sealed class TreasureBand { public int minClears; public StageReward reward; }
[Serializable] public sealed class TreasureConfig { public int unlockStage, dailyLimit; public string[] events; public TreasureBand[] bands; }
[Serializable] public sealed class ChallengeConfig
{
public int id, allyElement, element, durability, recommendedLevel, shardsPet, shards, lanes;
@@ -101,6 +106,9 @@ namespace HotUpdate.Gameplay
public EnemyConfig[] enemies;
public StageConfig[] stages;
public ChallengeConfig[] challenges;
public FriendConfig friends;
public FishConfig fishing;
public TreasureConfig treasure;
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)
@@ -203,6 +211,33 @@ namespace HotUpdate.Gameplay
foreach (int lane in challenge.enemyLanes) if (lane != 0) return false;
}
}
if (c.friends != null)
{
var friends = c.friends;
if (friends.limit != 20 || friends.requestLimit != 20 || friends.blockLimit != 20 || friends.sendLimit != 5 || friends.receiveLimit != 5) return false;
if (friends.gift == null || friends.gift.coins != 20 || friends.gift.candy != 0 || friends.gift.balls != 0 || friends.gift.tickets != 0) return false;
}
if (c.fishing != null)
{
var fish = c.fishing;
if (fish.unlockStage != 1 || fish.ballCost != 1 || fish.dailyLimit != 5 || fish.duration != 20 || fish.grace != 3 || fish.fishCount != 8 || fish.windowMs != 900 || fish.tiers == null || fish.tiers.Length != 3) return false;
if (fish.tiers[0] == null || fish.tiers[1] == null || fish.tiers[2] == null || fish.tiers[0].minScore != 3 || fish.tiers[1].minScore != 5 || fish.tiers[2].minScore != 8) return false;
if (fish.tiers[0].reward == null || fish.tiers[0].reward.coins != 10 || fish.tiers[0].reward.candy != 0) return false;
if (fish.tiers[1].reward == null || fish.tiers[1].reward.coins != 20 || fish.tiers[1].reward.candy != 0) return false;
if (fish.tiers[2].reward == null || fish.tiers[2].reward.coins != 30 || fish.tiers[2].reward.candy != 5) return false;
for (int i = 0; i < 3; i++) if (fish.tiers[i].reward.balls != 0 || fish.tiers[i].reward.tickets != 0) return false;
}
if (c.treasure != null)
{
var hunt = c.treasure;
if (hunt.unlockStage != 1 || hunt.dailyLimit != 3 || hunt.events == null || hunt.events.Length != 3 || hunt.bands == null || hunt.bands.Length != 3) return false;
foreach (string ev in hunt.events) if (string.IsNullOrEmpty(ev)) return false;
if (hunt.bands[0] == null || hunt.bands[1] == null || hunt.bands[2] == null || hunt.bands[0].minClears != 1 || hunt.bands[1].minClears != 4 || hunt.bands[2].minClears != 10) return false;
if (hunt.bands[0].reward == null || hunt.bands[0].reward.coins != 10 || hunt.bands[0].reward.candy != 0) return false;
if (hunt.bands[1].reward == null || hunt.bands[1].reward.coins != 20 || hunt.bands[1].reward.candy != 0) return false;
if (hunt.bands[2].reward == null || hunt.bands[2].reward.coins != 30 || hunt.bands[2].reward.candy != 5) return false;
for (int i = 0; i < 3; i++) if (hunt.bands[i].reward.balls != 0 || hunt.bands[i].reward.tickets != 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);
@@ -59,10 +59,14 @@ namespace HotUpdate.Gameplay
Panel _page;
GameObject _arena;
EnemyCombatPresentation _enemyFeedback;
BattleSkillPresentation _skillFeedback;
int _battleLoadVersion;
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;
// Per hero slot: T1 "Visual" and optional T2 "VisualT2" (same rig + evolution accessory), shown by tier.
Animator[] _heroAnimators, _heroT2Animators;
Transform[] _heroT1Visuals, _heroT2Visuals;
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;
@@ -82,7 +86,10 @@ namespace HotUpdate.Gameplay
int _mailPage;
string _mailSelected;
int _chestChoice = -1;
int _stageTab, _challenge, _rankBoard;
int _stageTab, _challenge, _rankBoard, _friendTab, _caught;
readonly int[] _caughtFish = new int[8], _caughtAt = new int[8];
long _friendPid;
FriendBook _friends;
RankBoard _rank;
double _receivedAt, _restGreetingUntil; long _serverUnix;
float _campRefresh;
@@ -136,13 +143,35 @@ namespace HotUpdate.Gameplay
case "goal_incomplete": return "请先完成教学关卡1-1。";
case "stale_revision": return "账户状态已更新,请根据当前余额重新操作。";
case "activity_not_ready": return "服务端正在确认挑战进度,请稍后重试。";
case "pending_activity": return "请先完成正在进行的弹珠或挑战。";
case "pending_activity": return "请先完成正在进行的弹珠、挑战或钓鱼。";
case "fish_locked": return "通过 1-1 后才能钓鱼。";
case "fish_limit": return "今天的钓鱼次数已用完,明天零点刷新。";
case "invalid_fish_tap": return "这次收杆的时间对不上,请在倒计时内再试。";
case "fish_expired": return "这局已经超时,请放弃后重新开始。";
case "activity_missing": 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 "friend_self": return "这是你自己的编号。";
case "friend_missing": return "没有找到这个玩家编号。";
case "friend_unavailable": return "这位探险家的营地暂时无法查看。";
case "friend_blocked": return "对方暂不接受互访。";
case "friend_exists": return "你们已经是好友了。";
case "friend_pending": return "申请已经发出,等待对方接受。";
case "friend_full": return "好友、申请或屏蔽名单已满。";
case "friend_request_missing": return "这则申请已经不在了,请刷新后再试。";
case "friend_required": return "成为好友后才能查看营地、赠礼或寻宝。";
case "treasure_locked": return "通过 1-1 后才能在好友营地寻宝。";
case "treasure_limit": return "今天的寻宝次数已用完,明天零点刷新。";
case "treasure_seen": return "今天已经找过这位好友的营地。";
case "friend_not_blocked": return "这位探险家不在屏蔽名单里。";
case "gift_sent": return "今天已经给这位好友送过礼了。";
case "gift_pair": return "同一对好友每天只能送出一个方向。";
case "gift_send_limit": return "今天的赠出次数已用完,明天零点刷新。";
case "gift_receive_limit": return "对方今天收到的赠礼已达上限。";
case "stage_locked": return "请先通过前一关,再挑战这一关。";
case "empty_formation": return "请在本关开放的路线部署伙伴。";
case "invalid_formation": return "站位不可用,请选择已拥有的伙伴和本关开放的路线。";
@@ -201,6 +230,8 @@ namespace HotUpdate.Gameplay
case "UI_PrototypeChest": RefreshChest(); break;
case "UI_PrototypeChallenges": RefreshChallenges(); break;
case "UI_PrototypeRank": RefreshRank(); break;
case "UI_PrototypeFriends": RefreshFriends(); break;
case "UI_PrototypeFishing": RefreshFishing(); break;
}
}
void Failed(string error)
@@ -244,12 +275,17 @@ namespace HotUpdate.Gameplay
_shotMessage = "已恢复上次弹珠,继续播放与结算…"; RefreshPinball();
}
else if (!string.IsNullOrEmpty(r.state.pendingBattle) && _battle == null) yield return StartBattle();
else if (!string.IsNullOrEmpty(r.state.pendingFish) && (_page == null || _page.Go.name != "UI_PrototypeFishing"))
{
_caught = 0;
yield return Open("Fishing");
}
}
IEnumerator Execute(string action, Action<PrototypeReply> done = null, 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)
IEnumerator Execute(string action, Action<PrototypeReply> done = null, 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, int target = 0, int[] tapFish = null, int[] taps = null)
{
if (Blocked || _game == null) yield break;
var api = _api;
var command = api.Command(action, _game.State.revision, aim, pet, slot, activity, stageId, item, day, mailId, mailIds, chest, choice);
var command = api.Command(action, _game.State.revision, aim, pet, slot, activity, stageId, item, day, mailId, mailIds, chest, choice, target, tapFish, taps);
_busy = true; RefreshCurrent();
PrototypeReply r = null; string error = null;
yield return api.Send(command, (value, failure) => { r = value; error = failure; });
@@ -282,15 +318,15 @@ namespace HotUpdate.Gameplay
{
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 "capture": case "goal.claim": case "daily.claim": case "signin.claim": case "mail.claim": case "mail.claimAll": case "chest.claimDaily": case "chest.open": case "friend.gift": case "friend.treasure": return UiSound.Claim;
case "train": case "evolve": case "shop.buy": case "camp.place": case "camp.store": case "camp.interact": case "friend.accept": case "friend.request": 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" && key != "Events" && key != "Rank") 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" && key != "Friends") return false;
if (_locked || _opening || _busy) return true;
AttachProfile();
if (_needsSync) { StartCoroutine(Synchronize()); return true; }
@@ -325,6 +361,10 @@ namespace HotUpdate.Gameplay
if (_game.State.schema < 11) _dialog.Show("冒险排行", "排行服务正在更新,请稍后重新进入。");
else { _rankBoard = 0; _rank = null; StartCoroutine(Open("Rank")); }
break;
case "Friends":
if (_game.State.schema < 12) _dialog.Show("好友", "好友服务正在更新,请稍后重新进入。");
else { _friendTab = 0; _friendPid = 0; _friends = null; StartCoroutine(Open("Friends")); }
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;
@@ -348,6 +388,7 @@ namespace HotUpdate.Gameplay
}
void Home()
{
_battleLoadVersion++;
if (_page != null) _page.Go.SetActive(false);
_page = null; _shot = null; _battle = null;
_decorSlot = -1; _decorItem = -1;
@@ -370,7 +411,7 @@ namespace HotUpdate.Gameplay
_page = panel; _page.Go.SetActive(true); _hud.gameObject.SetActive(false);
_menuMusic?.SetInMenu(screen != "Battle" && screen != "Pinball");
if (screen == "Settings") panel.Wire("Close", () => { if (!Blocked) Home(); });
else if (screen != "Battle") panel.Wire("Back", () => { if (!Blocked && _shot == null && string.IsNullOrEmpty(_game.State.pendingShot)) Home(); });
else if (screen != "Battle") panel.Wire("Back", () => { if (!Blocked && _shot == null && string.IsNullOrEmpty(_game.State.pendingShot) && string.IsNullOrEmpty(_game.State.pendingFish)) Home(); });
if (screen == "Mail")
{
foreach (var text in loaded.GetComponentsInChildren<TextMeshProUGUI>(true)) text.richText = false;
@@ -402,8 +443,24 @@ namespace HotUpdate.Gameplay
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")));
panel.Wire("Fish", () => {
if (Blocked) return;
if (_game.State.schema < 13 || _config.fishing == null) _dialog.Show("小镇钓鱼", "钓鱼服务正在更新,请稍后重新进入。");
else StartCoroutine(Open("Fishing"));
});
RefreshChallenges();
}
else if (screen == "Fishing")
{
panel.Wire("Start", () => { if (!Blocked) { _caught = 0; StartCoroutine(Execute("fish.begin")); } });
panel.Wire("Finish", () => { if (!Blocked && !string.IsNullOrEmpty(_game.State.pendingFish)) StartCoroutine(Execute("fish.finish", ShowFishResult, activity: _game.State.pendingFish, tapFish: CaughtIds(), taps: CaughtTimes())); });
panel.Wire("Abandon", () => {
if (Blocked || string.IsNullOrEmpty(_game.State.pendingFish)) return;
_dialog.Show("放弃这局", "放弃后不返还弹珠,也不发奖励。", () => StartCoroutine(Execute("fish.abandon", ShowFishResult, activity: _game.State.pendingFish)));
});
for (int i = 0; i < 8; i++) { int index = i; panel.Wire("Fish" + i, () => CatchFish(index)); }
RefreshFishing();
}
else if (screen == "Rank")
{
for (int i = 0; i < 6; i++)
@@ -414,6 +471,28 @@ namespace HotUpdate.Gameplay
RefreshRank();
StartCoroutine(LoadRank(_rankBoard));
}
else if (screen == "Friends")
{
for (int i = 0; i < 4; i++) { int tab = i; panel.Wire("FriendTab" + i, () => { if (!Blocked) { _friendTab = tab; RefreshFriends(); } }); }
for (int i = 0; i < 8; i++) { int row = i; panel.Wire("FriendRow" + i, () => SelectFriend(row)); }
panel.Wire("Lookup", () => { if (!Blocked && FriendTarget(out int pid)) StartCoroutine(FriendAct("friend.lookup", pid)); });
panel.Wire("Request", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.request", (int)_friendPid)); });
panel.Wire("Accept", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.accept", (int)_friendPid)); });
panel.Wire("Decline", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.decline", (int)_friendPid)); });
panel.Wire("Cancel", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.cancel", (int)_friendPid)); });
panel.Wire("Remove", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.remove", (int)_friendPid)); });
panel.Wire("Block", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.block", (int)_friendPid)); });
panel.Wire("Unblock", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.unblock", (int)_friendPid)); });
panel.Wire("Gift", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.gift", (int)_friendPid, true)); });
panel.Wire("Visit", () => { if (!Blocked && _friendPid > 0) StartCoroutine(FriendAct("friend.visit", (int)_friendPid)); });
panel.Wire("Treasure", () => {
if (Blocked || _friendPid <= 0) return;
if (_game.State.schema < 14 || _config.treasure == null) { _dialog.Show("营地寻宝", "寻宝服务正在更新,请稍后重新进入。"); return; }
StartCoroutine(FriendAct("friend.treasure", (int)_friendPid));
});
RefreshFriends();
StartCoroutine(FriendAct("friend.list", 0));
}
else if (screen == "Settings")
{
panel.Wire("ToggleMusic", () => { if (Blocked) return; PrototypeClientPrefs.Music = !PrototypeClientPrefs.Music; RefreshSettings(); });
@@ -1056,13 +1135,17 @@ namespace HotUpdate.Gameplay
if (prefab == null) { _dialog.Show("战场加载失败", "请返回营地后重试。"); yield break; }
_arena = Instantiate(prefab, transform); _arena.name = "PrototypeArena_Runtime"; _arena.SetActive(false);
_enemyFeedback = _arena.GetComponentInChildren<EnemyCombatPresentation>(true);
_skillFeedback = _arena.GetComponentInChildren<BattleSkillPresentation>(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();
_heroT1Visuals = _heroViews.Select(v => v == null ? null : v.Find("Visual")).ToArray();
_heroT2Visuals = _heroViews.Select(v => v == null ? null : v.Find("VisualT2")).ToArray();
_heroAnimators = _heroViews.Select((v, k) => v == null ? null : (_heroT1Visuals[k] ?? v).GetComponentInChildren<Animator>(true)).ToArray();
_heroT2Animators = _heroT2Visuals.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();
@@ -1111,10 +1194,22 @@ namespace HotUpdate.Gameplay
}
challengeStage = battleConfig.challenges[challengeId - 1].ToStage();
}
_enemyFeedback?.ResetPlayback();
CombatEvent[] traceEvents=null;
if(remote.snapshot.rulesVersion==3)
{
if(_skillFeedback==null){_dialog.Show("战斗资源未就绪","技能表现资源尚未加载,请更新资源后重试。");yield break;}
int loadVersion=++_battleLoadVersion;string traceError=null;_opening=true;
yield return api.LoadCombat(remote,(events,error)=>{traceEvents=events;traceError=error;});
_opening=false;
if(api!=_api||loadVersion!=_battleLoadVersion)yield break;
if(traceEvents==null){_dialog.Show("战斗数据加载失败",Reason(traceError)+",返回关卡后可继续本场战斗。");yield break;}
}
_enemyFeedback?.ResetPlayback();_skillFeedback?.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;
if(traceEvents!=null)_battle.LoadTrace(remote.trace,traceEvents);
yield return Open("Battle");
RefreshBattle();
DrainBattleFeedback(_battle.Time);
}
void RefreshBattle()
{
@@ -1136,16 +1231,24 @@ namespace HotUpdate.Gameplay
_heroViews[i].gameObject.SetActive(h != null && h.Hp > 0);
if (_skillViews[i] != null) _skillViews[i].gameObject.SetActive(h != null && h.Hp > 0 && h.SkillPulse > 0);
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 (_projectiles[i] != null) _projectiles[i].gameObject.SetActive(!_battle.Authoritative && h != null && h.Hp > 0 && (_battle.Config?.pets[h.Pet].range ?? (h.Pet == 0 ? 2 : 8)) > 2 && h.Pulse > 0);
if (h == null) continue;
var animator = _heroAnimators[i];
bool evolved = h.Tier == 2 && _heroT2Visuals[i] != null;
if (_heroT2Visuals[i] != null)
{
if (_heroT1Visuals[i] != null) _heroT1Visuals[i].gameObject.SetActive(!evolved);
_heroT2Visuals[i].gameObject.SetActive(evolved);
}
var animator = evolved ? _heroT2Animators[i] : _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);
// The placeholder crest only marks T2 for partners without authored evolution art.
var crest = _heroViews[i].Find("T2Crest"); if (crest != null) crest.gameObject.SetActive(h.Tier == 2 && !evolved);
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");
if(_battle.Authoritative)_skillFeedback.Animate(animator,true,i,_battle.Time+(_battle.Finished?0:_battleAccumulator),h.CastStart,h.CastRelease,h.CastEnd,false);
else {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;
@@ -1177,9 +1280,10 @@ namespace HotUpdate.Gameplay
var animator = _enemyAnimators[i][e.Type];
if (animator != null)
{
animator.speed = _paused || _battle.Finished ? 0 : _speed;
if(_battle.Authoritative)_skillFeedback.Animate(animator,false,e.Type,_battle.Time+(_battle.Finished?0:_battleAccumulator),e.CastStart,e.CastRelease,e.CastEnd,e.Alive&&e.Moving);
else {animator.speed = _paused || _battle.Finished ? 0 : _speed;
animator.SetInteger("Motion", e.Alive && e.Moving ? 1 : 0);
if (e.Strikes > _enemyStrikes[i]) animator.SetTrigger("Attack");
if (e.Strikes > _enemyStrikes[i]) animator.SetTrigger("Attack");}
}
_enemyStrikes[i] = e.Strikes;
_enemyViews[i].localPosition = new Vector3(x, 0, e.Z);
@@ -1191,6 +1295,21 @@ namespace HotUpdate.Gameplay
_enemyViews[i].localScale = Vector3.one * size * (e.Alive ? 1 - e.Pulse * .5f : fade);
}
}
if(_battle.Authoritative)_skillFeedback.Render(_battle.Time+(_battle.Finished?0:_battleAccumulator),CombatPoint,CombatAlive);
}
Vector3 CombatPoint(int entity)
{
Vector3 position;Transform view;
if(entity<100){var hero=_battle.Heroes.First(h=>h.Pet==entity);position=new Vector3(PrototypeBattle.LaneX(hero.Lane),0,hero.Z);view=_heroViews[entity];}
else {var enemy=_battle.Enemies.FirstOrDefault(e=>e.Index==entity-100);if(enemy==null)return _arena.transform.position;position=PrototypeBattle.EnemyPosition(enemy);view=_enemyViews[entity-100];}
var muzzle=view.Find("SkillMuzzle");
return _arena.transform.TransformPoint(position)+(muzzle==null?Vector3.up*.8f:view.TransformVector(muzzle.localPosition));
}
bool CombatAlive(int entity)=>entity<100?_battle.Heroes.Any(h=>h.Pet==entity&&h.Hp>0):_battle.Enemies.Any(e=>e.Index==entity-100&&e.Alive);
void DrainBattleFeedback(float displayTime)
{
while(_battle.TryDequeueCue(out var cue))if(!_battle.Authoritative||displayTime-_battle.Time<.15f)_enemyFeedback?.Play(cue,!(_battle.Authoritative&&_skillFeedback?.ProfileCount==10&&cue.Kind==PrototypeBattle.CueKind.EnemyHit&&cue.Type<2));
while(_battle.TryDequeueSkillCue(out var e)){_skillFeedback?.Play(e,displayTime,CombatPoint,_battle.Config??_config);if(e.kind=="battle_end")_enemyFeedback?.ResetPlayback();}
}
int CounterElement(int enemy)
{
@@ -1199,6 +1318,55 @@ namespace HotUpdate.Gameplay
if (cycle[(i + 1) % cycle.Length] == enemy) return cycle[i];
return 0;
}
bool FishLive => _reply?.fish != null && !string.IsNullOrEmpty(_reply.fish.id) && _game != null && _game.State.pendingFish == _reply.fish.id;
int[] CaughtIds() { var ids = new int[_caught]; Array.Copy(_caughtFish, ids, _caught); return ids; }
int[] CaughtTimes() { var times = new int[_caught]; Array.Copy(_caughtAt, times, _caught); return times; }
void CatchFish(int index)
{
if (Blocked || !FishLive || _caught >= 8 || index < 0 || index >= _reply.fish.spots.Length) return;
for (int i = 0; i < _caught; i++) if (_caughtFish[i] == index) return;
var spot = _reply.fish.spots[index];
int ms = (int)Math.Max(0, (ServerNow - _reply.fish.startedAt) * 1000);
if (ms < spot.at || ms > spot.at + spot.window)
{
_page.Text("FishStatus", ms < spot.at ? "这只鱼还没咬钩" : "这只鱼已经游走了");
return;
}
_caughtFish[_caught] = index; _caughtAt[_caught] = ms; _caught++;
RefreshFishing();
}
void ShowFishResult(PrototypeReply r)
{
_caught = 0;
int score = r.result != null ? r.result.fishScore : 0;
var reward = r.result?.reward;
string pay = reward == null || (reward.coins == 0 && reward.candy == 0) ? "这次没有奖励。" : "金币 +" + reward.coins + (reward.candy > 0 ? ",糖果 +" + reward.candy : "") + "。";
if (reward != null && (reward.coins > 0 || reward.candy > 0)) UiSfx.Play(UiSound.Claim);
_dialog.Show("钓鱼结算", "服务器确认 " + score + " 分。\n" + pay);
RefreshFishing();
}
void RefreshFishing()
{
if (_page == null || _page.Go.name != "UI_PrototypeFishing" || _game == null) return;
var save = _game.State;
bool live = FishLive;
int left = live ? (int)Math.Max(0, _reply.fish.deadline - ServerNow) : 0;
_page.Text("Summary", "今日 " + save.fishPlayed + "/5 · 最佳 " + save.fishBest + " 分 · 每局 1 枚弹珠");
_page.Text("FishStatus", live ? "剩余 " + left + " 秒 · 已钓 " + _caught + " 只\n点中鱼影后收杆,积分由服务器计算" : save.fishPlayed >= 5 ? "今天的次数已用完,明天零点刷新。" : "点击开始。20 秒内点中鱼影,3 分起有金币。");
_page.Button("Start").interactable = !Blocked && !live && save.fishPlayed < 5 && save.balls > 0 && save.clearedStages != null && save.clearedStages.Length > 0 && save.clearedStages[0] == 1;
_page.Button("Finish").interactable = !Blocked && live;
_page.Button("Abandon").interactable = !Blocked && live;
for (int i = 0; i < 8; i++)
{
bool caught = false;
for (int n = 0; n < _caught; n++) if (_caughtFish[n] == i) caught = true;
var spot = live && _reply.fish.spots != null && i < _reply.fish.spots.Length ? _reply.fish.spots[i] : null;
int ms = live ? (int)Math.Max(0, (ServerNow - _reply.fish.startedAt) * 1000) : 0;
bool open = spot != null && ms >= spot.at && ms <= spot.at + spot.window && !caught;
_page.Text("Fish" + i, spot == null ? "鱼影 " + (i + 1) : (caught ? "已钓到\n" : open ? "咬钩了\n" : "等待\n") + (spot.at / 1000f).ToString("0.0") + " 秒 · " + spot.points + " 分");
_page.Button("Fish" + i).interactable = !Blocked && open;
}
}
void RefreshChallenges()
{
if (_page == null || _page.Go.name != "UI_PrototypeChallenges" || _config.challenges == null) return;
@@ -1260,6 +1428,115 @@ namespace HotUpdate.Gameplay
_page.Text("RankScore" + i, RankScore(_rankBoard, row.score));
}
}
static readonly string[] FriendTabs = { "好友", "收到", "发出", "屏蔽" };
IEnumerator FriendAct(string action, int target, bool gift = false)
{
yield return Execute(action, r =>
{
_friends = r.friends;
if (r.friends != null && r.friends.card != null) _friendPid = r.friends.card.pid;
RefreshFriends();
if (gift && _config.friends != null) _dialog.Show("赠礼已送达", "对方收到 " + _config.friends.gift.coins + " 金币。同一对好友每天只送出一个方向。");
if (action == "friend.treasure" && r.friends != null && r.friends.treasure != null && r.friends.treasure.reward != null && _config.treasure != null)
{
int index = r.friends.treasure.index;
string found = index >= 0 && index < _config.treasure.events.Length ? _config.treasure.events[index] : "营地里的小发现";
var reward = r.friends.treasure.reward;
_dialog.Show("寻宝结果", found + "\n金币 +" + reward.coins + (reward.candy > 0 ? ",糖果 +" + reward.candy : "") + "。奖励来自活动池,不会减少对方的营地。");
}
}, target: target);
}
bool FriendTarget(out int pid)
{
pid = 0;
var field = _page.Node("Input_Pid").GetComponent<TMP_InputField>();
if (field == null || !int.TryParse(field.text, out pid) || pid <= 0)
{
_dialog.Show("查找好友", "请输入玩家编号。");
return false;
}
return true;
}
void SelectFriend(int row)
{
var rows = CurrentFriends();
if (row < 0 || row >= rows.Length || rows[row] == null) return;
_friendPid = rows[row].pid;
RefreshFriends();
}
FriendCard[] CurrentFriends()
{
if (_friends == null) return new FriendCard[0];
FriendCard[] rows = _friendTab == 1 ? _friends.incoming : _friendTab == 2 ? _friends.outgoing : _friendTab == 3 ? _friends.blocked : _friends.friends;
return rows ?? new FriendCard[0];
}
void RefreshFriends()
{
if (_page == null || _page.Go.name != "UI_PrototypeFriends") return;
for (int i = 0; i < FriendTabs.Length; i++) _page.Text("FriendTab" + i, (_friendTab == i ? "✓ " : "") + FriendTabs[i]);
var book = _friends;
int gift = _config.friends != null ? _config.friends.gift.coins : 20;
if (book == null) _page.Text("Summary", "正在读取好友…");
else _page.Text("Summary", "我的编号 " + _boundProfile + " · 好友 " + (book.friends == null ? 0 : book.friends.Length) + "/" + book.limit + "\n今日送出 " + book.sent + "/" + book.sendLimit + " · 收到 " + book.received + "/" + book.receiveLimit + (_game != null && _game.State.schema >= 14 ? " · 寻宝 " + _game.State.treasurePlayed + "/3" : ""));
var rows = CurrentFriends();
_page.Text("FriendEmpty", _friendTab == 0 ? "还没有好友\n用编号查找后发送申请" : "这里还没有人");
_page.Node("Txt_FriendEmpty").gameObject.SetActive(book != null && rows.Length == 0);
for (int i = 0; i < 8; i++)
{
bool show = i < rows.Length;
_page.Node("Btn_FriendRow" + i).gameObject.SetActive(show);
if (!show) continue;
var row = rows[i];
_page.Text("FriendName" + i, (row.pid == _friendPid ? "✓ " : "") + row.name + " " + row.pid);
_page.Text("FriendMeta" + i, FriendRelation(row.relation));
}
FriendCard selected = book != null && book.card != null && book.card.pid == _friendPid ? book.card : null;
if (selected == null) foreach (var row in rows) if (row != null && row.pid == _friendPid) selected = row;
if (selected == null)
{
_page.Text("DetailName", "选择一位探险家");
_page.Text("DetailBody", "输入编号可以查找。成为好友后可以查看阵容和营地,每天只能向同一位好友送出一个方向的 " + gift + " 金币。");
}
else
{
_page.Text("DetailName", selected.name + " · 编号 " + selected.pid);
_page.Text("DetailBody", FriendDetail(selected, gift));
}
string relation = selected == null ? "" : selected.relation;
FriendAllow("Request", relation == "none");
FriendAllow("Accept", relation == "incoming");
FriendAllow("Decline", relation == "incoming");
FriendAllow("Cancel", relation == "outgoing");
FriendAllow("Remove", relation == "friend");
FriendAllow("Block", selected != null && relation != "blocked");
FriendAllow("Unblock", relation == "blocked");
FriendAllow("Gift", relation == "friend");
FriendAllow("Visit", relation == "friend");
FriendAllow("Treasure", relation == "friend");
}
void FriendAllow(string name, bool on) { _page.Button(name).interactable = !Blocked && on; }
string FriendDetail(FriendCard card, int gift)
{
if (card.formation == null || card.formation.Length != 6) return FriendRelation(card.relation) + "\n成为好友后可以查看阵容,并每天互送 " + gift + " 金币。";
var names = new List<string>();
foreach (int pet in card.formation) if (pet >= 0 && pet < _config.pets.Length) names.Add(_config.pets[pet].name);
string line = names.Count == 0 ? "阵容空着" : "阵容 " + string.Join("、", names);
if (card.camp == null || card.camp.Length != 3) return line + "\n点击看营地后显示布置。";
var decor = new List<string>();
if (_config.camp != null) foreach (int id in card.camp) if (id >= 0 && id < _config.camp.decorations.Length) decor.Add(_config.camp.decorations[id].name);
return line + "\n营地 " + (decor.Count == 0 ? "还没有摆放装饰" : string.Join("、", decor));
}
static string FriendRelation(string relation)
{
switch (relation)
{
case "friend": return "好友";
case "incoming": return "待接受";
case "outgoing": return "已申请";
case "blocked": return "已屏蔽";
default: return "未添加";
}
}
IEnumerator StartChallenge()
{
if (Blocked || _config.challenges == null) yield break;
@@ -1318,7 +1595,7 @@ namespace HotUpdate.Gameplay
}
void FinishBattle(bool abandon = false)
{
if (abandon) _abandonRequested = true;
if (abandon) {_abandonRequested = true;_skillFeedback?.ResetPlayback();_enemyFeedback?.ResetPlayback();}
if (_settled || Blocked || (!_abandonRequested && Time.realtimeSinceStartupAsDouble < _battleReady)) return;
_settled = true;
StartCoroutine(Execute(_abandonRequested ? "battle.abandon" : "battle.finish", r => ShowBattleResult(r.result), activity: _battle.Id));
@@ -1349,6 +1626,7 @@ namespace HotUpdate.Gameplay
else if (_page?.Go.name == "UI_PrototypeShop") RefreshShop();
else if (_page?.Go.name == "UI_PrototypeMail") RefreshMail();
else if (_page?.Go.name == "UI_PrototypeChest") RefreshChest();
else if (_page?.Go.name == "UI_PrototypeFishing") RefreshFishing();
}
if (!Blocked && _shot == null && _battle == null && _reply.daily.resetAt <= _serverUnix + (long)(Time.realtimeSinceStartupAsDouble - _receivedAt)) { StartCoroutine(Synchronize()); return; }
_recoveryPoll += Time.unscaledDeltaTime;
@@ -1370,11 +1648,12 @@ namespace HotUpdate.Gameplay
if (_battle != null && _page != null && _page.Go.name == "UI_PrototypeBattle")
{
_enemyFeedback?.SetPlayback(_paused, _speed);
_skillFeedback?.SetPlayback(_paused, _speed);
if (!_paused && !_battle.Finished)
{
_battleAccumulator += Time.deltaTime * _speed;
while (_battleAccumulator >= PrototypeBattle.Step) { _battleAccumulator -= PrototypeBattle.Step; _battle.Tick();
while (_battle.TryDequeueCue(out var cue)) _enemyFeedback?.Play(cue); }
DrainBattleFeedback(_battle.Time+_battleAccumulator); }
}
RefreshBattle();
if (_battle.Finished) FinishBattle();
@@ -34,8 +34,13 @@ namespace HotUpdate.Gameplay
public int[] chests;
public int chestDailyClaimed;
public int[] challengeBadges, challengeBestMs;
public long storyRankAt;
public long[] challengeRankAt;
public int storyRankAt;
public int[] challengeRankAt;
public int[] friendIds, friendIn, friendOut, friendBlocked, giftSent, giftFrom;
public int fishPlayed, fishBest;
public string pendingFish;
public int treasurePlayed;
public int[] treasureSeen;
public PrototypeMail[] mails;
public int selectedStage = 1;
public int[] clearedStages = new int[5];
@@ -137,9 +142,16 @@ namespace HotUpdate.Gameplay
if (!Valid(State)) throw new InvalidOperationException("原型存档版本或内容无效。");
if (State.ballRecoveryUnix <= 0) Commit(s => s.ballRecoveryUnix = Math.Max(1, _clock()));
}
static bool FriendList(int[] ids, int cap)
{
if (ids == null || ids.Length > cap) return false;
var seen = new HashSet<int>();
foreach (int id in ids) if (id <= 0 || !seen.Add(id)) return false;
return true;
}
public static bool Valid(PrototypeSave s)
{
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;
if (s == null || s.schema < 1 || s.schema > 14 || 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)
{
@@ -195,6 +207,19 @@ namespace HotUpdate.Gameplay
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;
}
if (s.schema >= 12)
{
if (!FriendList(s.friendIds, 20) || !FriendList(s.friendIn, 20) || !FriendList(s.friendOut, 20) || !FriendList(s.friendBlocked, 20) || !FriendList(s.giftSent, 5) || !FriendList(s.giftFrom, 5)) return false;
}
if (s.schema >= 13)
{
if (s.fishPlayed < 0 || s.fishPlayed > 5 || s.fishBest < 0 || s.fishBest > 16) return false;
if (!string.IsNullOrEmpty(s.pendingFish) && (s.pendingFish.Length < 16 || s.pendingFish.Length > 64)) return false;
}
if (s.schema >= 14)
{
if (s.treasurePlayed < 0 || s.treasurePlayed > 3 || s.treasureSeen == null || s.treasureSeen.Length != s.treasurePlayed || !FriendList(s.treasureSeen, 3)) return false;
}
return true;
}
void Commit(Action<PrototypeSave> change)
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace HotUpdate.Gameplay
{
/// <summary>Independent review player for a C++ generated immutable event trace. Never computes damage or rewards.</summary>
public sealed class SkillSlicePlayer : MonoBehaviour
{
[Serializable] public sealed class View
{
public int id;
public Transform root, muzzle;
public Animator animator;
public TextMesh label;
[Range(0, 1)] public float impact = .5f;
[NonSerialized] public int hp, maxHp, shield, cast, start, release, end, lastMove;
[NonSerialized] public bool casting, dead, moving;
[NonSerialized] public float idleClock;
}
[Serializable] public sealed class Pool { public ParticleSystem[] instances; [NonSerialized] public int next; }
[SerializeField] TextAsset _trace;
[SerializeField] View[] _views;
// Charge, projectile, fire impact, stone impact, shield. Authored in a prefab, no runtime construction.
[SerializeField] Pool[] _effects;
[SerializeField] AudioClip[] _clips;
[SerializeField] AudioSource[] _voices;
[SerializeField] EnemyCombatPresentation _enemyFeedback;
[SerializeField] TextMesh _status;
[SerializeField] bool _autoPlay = true;
sealed class Lease { public ParticleSystem ps; public int generation; }
sealed class Shot { public Lease lease; public View target; public Vector3 start; public float born, arrival; }
readonly Dictionary<int, Lease> _charges = new Dictionary<int, Lease>();
readonly Dictionary<int, Lease> _shields = new Dictionary<int, Lease>();
readonly Dictionary<ParticleSystem, int> _generations = new Dictionary<ParticleSystem, int>();
readonly List<Shot> _shots = new List<Shot>();
JArray _events;
JObject _document;
int _cursor, _generation, _voice;
int[] _voiceCasts;
float _clock, _speed = 1;
bool _paused, _loaded;
public bool Finished { get; private set; }
public int ConsumedEvents => _cursor;
public int EventCount => _events?.Count ?? 0;
public int PlayedSounds { get; private set; }
public int ActiveShots => _shots.Count;
public int ActiveCharges => _charges.Count;
public int ActiveShields => _shields.Count;
public float Clock => _clock;
public View[] Views => _views;
public bool Won => (bool)_document["won"];
public bool FinalStateMatches => _document != null && ((JArray)_document["final"]).All(t => { var v=Find((int)t["id"]); return v.hp==(int)t["hp"] && v.shield==(int)t["shield"] && Mathf.Abs(v.root.localPosition.z-(int)t["z"]/1000f)<.0001f && v.dead==((int)t["hp"]<=0||((bool?)t["exited"]??false)); });
View Find(int id) => Array.Find(_views, v => v.id == id) ?? throw new InvalidOperationException("Missing trace entity " + id);
void OnEnable() { PrototypeClientPrefs.AudioPreferencesChanged += AudioChanged; if (_autoPlay) Restart(); }
void OnDisable() { PrototypeClientPrefs.AudioPreferencesChanged -= AudioChanged; Clear(); _loaded=false; }
void AudioChanged() { if (!PrototypeClientPrefs.Sfx) foreach (var v in _voices) v.Stop(); }
public void Restart()
{
Clear();
_document=JObject.Parse(_trace.text);
if ((int)_document["schema"]!=1 || (string)_document["kind"]!="isolated_skill_slice" || (int)_document["tickMs"]!=50) throw new InvalidOperationException("Unsupported skill trace");
_events=(JArray)_document["events"];
int lastTick=-1;
for(int i=0;i<_events.Count;i++) {int tick=(int)_events[i]["tick"];if((int)_events[i]["seq"]!=i||tick<lastTick)throw new InvalidOperationException("Unordered trace");lastTick=tick;}
foreach(var token in (JArray)_document["initial"])
{
var v=Find((int)token["id"]);v.hp=(int)token["hp"];v.maxHp=(int)token["maxHp"];v.shield=(int)token["shield"];v.cast=0;v.lastMove=-100;v.casting=v.dead=v.moving=false;v.idleClock=0;
v.root.gameObject.SetActive(true);v.root.localPosition=new Vector3(PrototypeBattle.LaneX((int)token["lane"]),0,(int)token["z"]/1000f);
if(v.animator!=null){v.animator.enabled=true;v.animator.speed=0;v.animator.Rebind();v.animator.Update(0);}
}
_cursor=0;_clock=0;_paused=false;_speed=1;Finished=false;PlayedSounds=0;_voiceCasts=new int[_voices.Length];_loaded=true;
_enemyFeedback?.ResetPlayback();Consume();Render();
}
void Clear()
{
if(_effects!=null)foreach(var pool in _effects)foreach(var ps in pool.instances)if(ps!=null)ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);
if(_voices!=null)foreach(var source in _voices)if(source!=null)source.Stop();
_charges.Clear();_shots.Clear();_shields.Clear();_generations.Clear();_enemyFeedback?.ResetPlayback();
}
bool Current(Lease lease) => lease!=null && _generations.TryGetValue(lease.ps,out int n) && n==lease.generation;
void Stop(Lease lease) {if(Current(lease)){lease.ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);_generations.Remove(lease.ps);}}
Lease Effect(int kind,Vector3 position)
{
var pool=_effects[kind];var ps=pool.instances[pool.next++%pool.instances.Length];ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);ps.transform.position=position;
foreach(var child in ps.GetComponentsInChildren<ParticleSystem>()){var main=child.main;main.simulationSpeed=_speed;}
var lease=new Lease{ps=ps,generation=++_generation};_generations[ps]=lease.generation;ps.Play(true);
foreach(var child in ps.GetComponentsInChildren<ParticleSystem>())if(child.main.loop)child.Emit(1);
return lease;
}
void Sound(int kind,int cast=0)
{
if(!PrototypeClientPrefs.Sfx||_paused)return;
int index=_voice++%_voices.Length;var source=_voices[index];source.Stop();source.clip=_clips[kind];source.pitch=1;source.volume=.7f/_voices.Length;_voiceCasts[index]=cast;source.Play();PlayedSounds++;
}
void EndCharge(int cast)
{
if(_charges.TryGetValue(cast,out var lease)){Stop(lease);_charges.Remove(cast);}
for(int i=0;i<_voices.Length;i++)if(_voiceCasts[i]==cast&&_voices[i].clip==_clips[0])_voices[i].Stop();
}
public void SetPlayback(bool paused,float speed)
{
if(speed!=1&&speed!=2)throw new ArgumentOutOfRangeException(nameof(speed));
if(_paused==paused&&_speed==speed)return;
foreach(var pool in _effects)foreach(var ps in pool.instances)
{
foreach(var child in ps.GetComponentsInChildren<ParticleSystem>()){var main=child.main;main.simulationSpeed=speed;}
if(paused&&!_paused)ps.Pause(true);else if(!paused&&_paused&&ps.IsAlive(true))ps.Play(true);
}
if(paused!=_paused)foreach(var source in _voices){if(paused)source.Pause();else source.UnPause();}
_enemyFeedback?.SetPlayback(paused,speed);_paused=paused;_speed=speed;if(_loaded)Render();
}
void Update() { if(_loaded)Advance(Time.unscaledDeltaTime); }
/// <summary>Also permits deterministic low-frame-rate replay in verification.</summary>
public void Advance(float seconds)
{
if(seconds<0||float.IsNaN(seconds)||float.IsInfinity(seconds))throw new ArgumentOutOfRangeException(nameof(seconds));
if(!_loaded||_paused||Finished)return;
_clock+=seconds*_speed;Consume();Render();
}
void Consume()
{
while(_cursor<_events.Count&&(int)_events[_cursor]["tick"]*.05f<=_clock+.00001f)
{
var e=(JObject)_events[_cursor++];string kind=(string)e["kind"],skill=(string)e["skill"]??"";int cast=(int?)e["cast"]??0;
var source=e["source"]==null?null:Find((int)e["source"]);var target=e["target"]==null?null:Find((int)e["target"]);
float eventTime=(int)e["tick"]*.05f;bool fresh=_clock-eventTime<.15f;
if(kind=="cast_start")
{
source.cast=cast;source.start=(int)e["tick"];source.release=(int)e["release"];source.end=(int)e["end"];source.casting=true;
if(source.id==1&&fresh){_charges[cast]=Effect(0,source.muzzle.position);Sound(0,cast);}
}
else if(kind=="release") {EndCharge(cast);if(source.id==1&&fresh)Sound(1);}
else if(kind=="cancel"||kind=="cast_end") {EndCharge(cast);if(source.cast==cast)source.casting=false;}
else if(kind=="move") {var p=target.root.localPosition;p.z=(int)e["z"]/1000f;target.root.localPosition=p;target.moving=true;target.lastMove=(int)e["tick"];}
else if(kind=="projectile")
{
float arrival=(int)e["arrival"]*.05f;
if(arrival>_clock){var start=source.muzzle.position;_shots.Add(new Shot{lease=Effect(1,start),target=target,start=start,born=eventTime,arrival=arrival});}
}
else if(kind=="hit")
{
target.hp=(int)e["hp"];target.shield=(int)e["shield"];
if(fresh)
{
if(source.id==1){Effect(2,target.muzzle.position);Sound(2);}
else if(source.id==0){Effect(3,target.muzzle.position);Sound(3);}
else _enemyFeedback?.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyAttack,0,transform.InverseTransformPoint(target.muzzle.position),Vector3.back,(int)e["amount"]==0,transform.InverseTransformPoint(source.muzzle.position)));
}
if(target.shield<=0&&_shields.TryGetValue(target.id,out var shield)){Stop(shield);_shields.Remove(target.id);}
}
else if(kind=="shield")
{
target.shield=(int)e["shield"];if(_shields.TryGetValue(target.id,out var old))Stop(old);
if((int)e["expires"]*.05f>_clock){_shields[target.id]=Effect(4,target.muzzle.position);if(fresh)Sound(4);}
}
else if(kind=="shield_expired") {target.shield=0;if(_shields.TryGetValue(target.id,out var shield)){Stop(shield);_shields.Remove(target.id);}}
else if(kind=="death"||kind=="exit") {target.dead=true;if(kind=="death")target.hp=0;target.casting=false;EndCharge(target.cast);if(_shields.TryGetValue(target.id,out var shield)){Stop(shield);_shields.Remove(target.id);}}
else if(kind=="battle_end") {Finished=true;Clear();}
}
}
void Render()
{
foreach(var v in _views)
{
v.root.gameObject.SetActive(!v.dead);
if(v.label!=null)v.label.text=(v.id==0?"RockBall":v.id==1?"FireCat":"Hornlet")+" "+Mathf.CeilToInt(v.hp/1000f)+"/"+Mathf.CeilToInt(v.maxHp/1000f)+(v.shield>0?" Shield "+Mathf.CeilToInt(v.shield/1000f):"");
if(v.animator==null||v.dead)continue;
float tick=_clock*20,normalized;
if(v.casting&&tick<v.end)
{
normalized=tick<v.release?Mathf.InverseLerp(v.start,v.release,tick)*v.impact:Mathf.Lerp(v.impact,1,Mathf.InverseLerp(v.release,v.end,tick));
v.animator.Play("Attack",0,Mathf.Clamp(normalized,0,.999f));
}
else {bool walk=v.id>=100&&v.moving&&tick-v.lastMove<2;v.animator.SetInteger("Motion",walk?1:0);v.animator.Play(walk?"Walk":"Idle",0,_clock/2%1);}
v.animator.Update(0);
}
foreach(var pair in _charges)if(Current(pair.Value)){var v=_views.FirstOrDefault(x=>x.cast==pair.Key);if(v!=null)pair.Value.ps.transform.position=v.muzzle.position;}
foreach(var pair in _shields)if(Current(pair.Value))pair.Value.ps.transform.position=Find(pair.Key).muzzle.position;
for(int i=_shots.Count-1;i>=0;i--)
{
var shot=_shots[i];if(_clock>=shot.arrival||shot.target.dead||!Current(shot.lease)){Stop(shot.lease);_shots.RemoveAt(i);continue;}
float progress=Mathf.InverseLerp(shot.born,shot.arrival,_clock);
shot.lease.ps.transform.position=Vector3.Lerp(shot.start,shot.target.muzzle.position,progress)+Vector3.up*Mathf.Sin(progress*Mathf.PI);
}
if(_status!=null)_status.text="Skill slice "+(_paused?"Paused":Finished?Won?"Victory":"Finished":_speed+"x")+" "+_clock.ToString("F2")+"s\n"+"C++ trace / "+_cursor+" events";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dc3dec232f6e9c440908d0af103f7cfc
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", GuideTask(state));
SetText("Txt_Task", GuideTask(state, config));
SetText("Txt_AdventureHint", state.cleared ? "首通已完成 · 可重复挑战" : "首通另获糖果 40 · 不消耗体力");
if (config != null && state.schema >= 2)
{
@@ -100,13 +100,18 @@ namespace HotUpdate.UI
}
}
public static string GuideTask(Gameplay.PrototypeSave state)
public static string GuideTask(Gameplay.PrototypeSave state, Gameplay.PrototypeConfig config = null)
{
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 "用糖果做一次共享训练";
if (state.level < 2)
{
int cost = (config?.economy?.trainCost ?? 30) * state.level;
if (state.candy < cost) return state.balls > 0 ? "发射弹珠,补足训练糖果" : "领取免费补给,再收集训练糖果";
return "用糖果做一次共享训练";
}
return "布阵并完成教学关 1-1";
}