134 lines
5.7 KiB
C#
134 lines
5.7 KiB
C#
using Ananta.SDK.Network;
|
|
using Ananta.SDK.Serialization;
|
|
using Ananta.Server.Protocol.Client4229938;
|
|
using Ananta.Server.RpcTypes.Client4229938;
|
|
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
|
|
|
namespace Ananta.Server.Handlers.Game;
|
|
|
|
/// <summary>
|
|
/// Server-authoritative player vitals: HP ledger, damage settlement, knock-out and respawn.
|
|
///
|
|
/// Why this lives on the server and not in the client's local settlement: enemy casts are
|
|
/// fabricated by us (GameRouter.EnemyAiActions), so nothing on the client ever resolves a real
|
|
/// hit against the player — the "damage" is animation only. Without this file the player's HP
|
|
/// bar is decoration, which is exactly why the knocked-out HUD could sit at 100% forever with
|
|
/// nothing able to write a full value back.
|
|
///
|
|
/// Damage only lands when the GM panel releases the vitals lock (default: locked).
|
|
/// </summary>
|
|
internal sealed partial class GameRouter
|
|
{
|
|
/// <summary>
|
|
/// Damage one enemy hit deals. Mirrors LocalEnemyHitDamage (100) against a 1000 HP pool, so
|
|
/// ten swings drop the player — the same count the player needs to drop an enemy.
|
|
/// </summary>
|
|
internal const float PlayerHitDamage = 100f;
|
|
|
|
/// <summary>How long the player stays knocked out before the server revives them.</summary>
|
|
internal const int PlayerDownRespawnDelayMs = 8000;
|
|
|
|
/// <summary>HP floor while the vitals lock is on: the bar can drain, but never empties.</summary>
|
|
internal const float PlayerVitalsLockFloorHp = 1f;
|
|
|
|
/// <summary>
|
|
/// Settles one enemy hit on the player.
|
|
///
|
|
/// Called from the attack AI at the moment a cast goes out (delayed into the animation window,
|
|
/// because the client never reports a hit). The bar always moves — that is the whole point of
|
|
/// making the HP server-authoritative. What the vitals lock changes is whether it can reach
|
|
/// zero: locked, the drain stops at <see cref="PlayerVitalsLockFloorHp"/> and the player never
|
|
/// goes down; unlocked, zero means knock-out and the respawn timer.
|
|
/// </summary>
|
|
internal static async Task ApplyEnemyHitToPlayerAsync(
|
|
TcpSession session, WorldEntryState state, ulong enemyUid, uint skillId)
|
|
{
|
|
float hp;
|
|
bool locked;
|
|
lock (state.SyncRoot)
|
|
{
|
|
if (state.PlayerDowned)
|
|
return;
|
|
|
|
locked = state.PlayerLifeLocked;
|
|
var floor = locked ? PlayerVitalsLockFloorHp : 0f;
|
|
hp = MathF.Max(floor, state.PlayerHp - PlayerHitDamage);
|
|
state.PlayerHp = hp;
|
|
}
|
|
|
|
await PublishPlayerHpAsync(session, state, hp);
|
|
session.Log.Info($"[PLAYER-HIT] enemy={enemyUid} skill={skillId} damage={PlayerHitDamage} hp={hp} locked={locked}");
|
|
|
|
if (hp <= 0f)
|
|
await EnterPlayerDownedAsync(session, state);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the authoritative HP onto every id that can own a vitals bar, attribute table first
|
|
/// (hard rule 52) so the value is not clamped against a stale max.
|
|
/// </summary>
|
|
internal static async Task PublishPlayerHpAsync(TcpSession session, WorldEntryState state, float hp)
|
|
{
|
|
var units = PlayerVitalsUnits(state);
|
|
foreach (var unit in units)
|
|
{
|
|
await session.NotifyAsync(MethodId.SyncUnitAttrs,
|
|
UxSerializer.Serialize(CombatCodec.UnitAttrs(unit, CombatCodec.MaxHp)), CancellationToken.None);
|
|
await session.NotifyAsync(MethodId.SyncUnitHp,
|
|
UxSerializer.Serialize(CombatCodec.UnitHp(unit, hp)), CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The unit every packet to the player is addressed to. There is only one observable id space:
|
|
/// AskUseSkill (Releaser), AskMultipleSkillHit2 (ReleaserId) and AskReportLogicAgentSyncData
|
|
/// (AgentId) all report Profile.InitialUnitId, so this is the single target for vitals writes.
|
|
/// </summary>
|
|
internal static List<ulong> PlayerVitalsUnits(WorldEntryState state)
|
|
{
|
|
var units = new List<ulong>
|
|
{
|
|
state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId,
|
|
};
|
|
return units;
|
|
}
|
|
|
|
/// <summary>
|
|
/// HP reached zero: put the player into the knocked-out state the client already knows how to
|
|
/// render, and schedule the revive. Nothing here walks the retail death pipeline
|
|
/// (AskExitFallingDownToDeath) — that path has been observed black-screening on this build.
|
|
/// </summary>
|
|
private static async Task EnterPlayerDownedAsync(TcpSession session, WorldEntryState state)
|
|
{
|
|
ulong unit;
|
|
lock (state.SyncRoot)
|
|
{
|
|
if (state.PlayerDowned)
|
|
return;
|
|
state.PlayerDowned = true;
|
|
unit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
|
|
}
|
|
|
|
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncPlayerFallingDownState,
|
|
UxSerializer.Serialize(new SceneMethods.SyncPlayerFallingDownState { uid = unit, isEnter = true }),
|
|
CancellationToken.None);
|
|
|
|
session.Log.Info($"[PLAYER-DOWN] unit={unit} hp=0 respawnInMs={PlayerDownRespawnDelayMs}");
|
|
|
|
// Fire-and-forget: the player stays down on their own timer, and a session that goes away
|
|
// mid-wait must not take the AI worker with it.
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(PlayerDownRespawnDelayMs, CancellationToken.None);
|
|
await GameAdminBridge.RestorePlayerHealthAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
session.Log.Warn($"[PLAYER-DOWN] auto-respawn failed: {ex.Message}");
|
|
}
|
|
});
|
|
}
|
|
}
|