Game/GameXp.cs
using System;
/// <summary>
/// 等级系统(v0.7.8.19):XP 存云端 Stats(cr_xp,Increment 累加、跨机器持久),
/// 等级=**本地曲线从 XP 总值现算**(改曲线不用迁移数据)。
/// 局内他人等级:每台机器算好自己的等级写进自己 Ball 的 [Sync] AccountLevel(owner 可写),
/// 全场名牌可见;bot 恒 0 不显示。排行页走 Leaderboards.GetFromStat("cr_xp") 查全球榜。
/// 边界:Stats 是客户端上报(休闲向,别拿去当竞技天梯依据);写后立刻读要 FlushAndWaitAsync。
/// 进程内计数(本局击杀)重启清零只影响当局经验。
/// </summary>
public static class GameXp
{
public const string StatName = "cr_xp";
const double PlayedXp = 10; // 参与奖
const double KillXp = 20; // 每击杀
const double TopTenXp = 25; // 第 4~10 名
const double PodiumXp = 60; // 第 2~3 名
const double FirstXp = 100; // 个人第一名
const double WinXp = 50; // 团队夺冠
static double _localXp = -1; // -1 = 云端未加载
static TimeSince _sinceFetch = 999f; // 拉取节流(失败也 5s 再试)
static int _matchKills; // 本局击杀(结算消费后清零)
static int _cachedLevel; // 等级缓存(LevelFromXp 逐级 Math.Pow,别在每帧路径上重算)
static double _cachedLevelForXp = double.NaN; // 缓存对应的 XP 值
/// <summary> 本机等级(云端未加载时 0 = 不显示)。
/// ⚠️ 缓存(v0.7.8.105):`GameXp.MatchTick` 每帧读它写进 Ball.AccountLevel,而 LevelFromXp 是
/// 逐级 `Math.Pow` 的循环(30 级玩家每帧约 30 次 double Pow),等级只在 XP 变化时才变——
/// 按 XP 值缓存,命中即直接返回。 </summary>
public static int LocalLevel
{
get
{
if ( _localXp < 0 ) return 0;
if ( _cachedLevelForXp != _localXp )
{
_cachedLevelForXp = _localXp;
_cachedLevel = LevelFromXp( _localXp );
}
return _cachedLevel;
}
}
/// <summary> 菜单/HUD 用的一行文本(未加载返回空串,Label 留白) </summary>
public static string LevelText() =>
_localXp < 0 ? "" : $"LEVEL {LocalLevel} — {(int)_localXp} XP";
/// <summary> 等级曲线:升到 level 级累计需要 150 × (level-1)^1.5(L2=150,L5≈1200,L10≈4300,L20≈25500) </summary>
public static double XpForLevel( int level ) => 150.0 * Math.Pow( Math.Max( 0, level - 1 ), 1.5 );
public static int LevelFromXp( double xp )
{
int lvl = 1;
while ( lvl < 999 && xp >= XpForLevel( lvl + 1 ) ) lvl++;
return lvl;
}
/// <summary> 拉取本机云端 XP(节流 5s,失败自愈重试)。菜单 OnUpdate / 结算后调用。
/// ⚠️ 只升不降合并(v0.7.8.29):Increment 上传后会本地 Predict(缓存立即含新值),
/// 但云端 ingest 有延迟——结算后立刻 Refresh 会拿回旧值覆盖预测,观感"没加经验";
/// XP 单调递增,取 max 兼顾"另一台机器玩出了更高值"与"本地预测领先"两种情况 </summary>
public static async void EnsureLocalXp()
{
if ( Application.IsDedicatedServer || _sinceFetch < 5f ) return;
_sinceFetch = 0;
try
{
var stats = Sandbox.Services.Stats.LocalPlayer;
await stats.Refresh();
_localXp = Math.Max( _localXp, stats.Get( StatName ).Sum );
}
catch ( Exception e )
{
Log.Warning( $"[xp] local stat refresh failed: {e.Message}" );
}
}
/// <summary> 本机击杀记账(GameAchievements.LocalKill 顺带调) </summary>
public static void OnLocalKill() => _matchKills++;
/// <summary> 局间清账(v0.7.8.87):`_matchKills` 只在 Settlement 里清零,而退赛/取消局不走结算
/// → 上局击杀会漏进下一局结算白送经验 </summary>
public static void ResetMatchKills() => _matchKills = 0;
/// <summary>
/// 结算给经验(各端各自上报本机):参与 10 + 击杀×20 + 名次(1st=100 / 2~3rd=60 / 4~10th=25)+ 团队夺冠 50。
/// 随后立即 Flush 并重新拉取——下一局的等级展示就是新值。
/// </summary>
public static void Settlement( ScoreWire[] standings, long localSteamId, bool won )
{
if ( standings is null || standings.Length == 0 ) return;
double xp = PlayedXp + _matchKills * KillXp;
for ( int i = 0; i < standings.Length; i++ )
{
if ( standings[i].SteamId != localSteamId ) continue;
if ( i == 0 ) xp += FirstXp;
else if ( i < 3 ) xp += PodiumXp;
else if ( i < 10 ) xp += TopTenXp;
break;
}
if ( won ) xp += WinXp;
var kills = _matchKills;
_matchKills = 0;
Sandbox.Services.Stats.Increment( StatName, xp ); // 引擎内部已本地 Predict:本地缓存立即含新值
Sandbox.Services.Stats.Flush(); // 后台上传(云端 ingest 有延迟,别等它再读)
_sinceFetch = 999f; // 立刻允许重拉
RefreshLocalNow();
GameLog.Info( $"[xp] settlement +{xp:0} (kills={kills}, won={won})" );
}
/// <summary> 结算后的读回:**不 Refresh**(会用云端未 ingest 的旧值覆盖本地预测,观感"没加经验"),
/// 直接读本地 Predict 后的 Sum;下次 5s 节流的 EnsureLocalXp 再对齐云端(只升不降) </summary>
static void RefreshLocalNow()
{
try
{
var stats = Sandbox.Services.Stats.LocalPlayer;
_localXp = Math.Max( _localXp, stats.Get( StatName ).Sum );
}
catch ( Exception e )
{
Log.Warning( $"[xp] post-settlement refresh failed: {e.Message}" );
}
}
/// <summary> 每帧(GameHud.OnUpdate 调):把本机等级写进自己的球——[Sync] owner 写,全场名牌可见 </summary>
public static void MatchTick( CircleroyaleGame game )
{
var local = game.LocalBall;
if ( !local.IsValid() ) return;
var lvl = LocalLevel;
if ( lvl > 0 && local.AccountLevel != lvl ) local.AccountLevel = lvl;
}
}