3c1b5c44cd
PlayerRespawnSystem teleported a recovered player to base coords but never reset its server-only RegionTag (every other region-mover flips RegionTag + Position together). So dying ON an expedition left you at base still tagged Expedition: GhostRelevancy hid all base ghosts from you, base enemies ignored you, and the expedition field/zone-director kept counting you as "still out there" (waves never stopped). No self-recovery. - PlayerRespawnSystem: add RefRW<RegionTag> to the recovery query + set Region=Base alongside the reposition. - Harden: drop the hard RequireForUpdate<PlayerSpawner> (a transiently-missing spawner could strand dead players downed forever) -> TryGetSingleton with a BaseAnchor fallback, early-return only if both are absent. - PlayerRespawnSystemTests: add RegionTag to the harness + a regression (expedition death -> respawn at base with RegionTag reset to Base). 390/390 EditMode. Investigation: combat-overhaul workflow wf_c6c87dc5-9c3 (death lane). Base-death case was already correct. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
4.6 KiB
C#
90 lines
4.6 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// Server-authoritative player death→respawn timing. The "is dead" GATE is derived every predicted tick from
|
|
/// replicated Health by <see cref="PlayerDeathStateSystem"/> (so movement/aim/fire stop on both server and
|
|
/// owner-client); THIS system owns the timer + the authoritative recovery. Runs server-only in the plain
|
|
/// <see cref="SimulationSystemGroup"/> AFTER the predicted group, so it observes this tick's post-damage Health.
|
|
/// On first seeing Health<=0 it schedules a respawn tick; once due it refills Health to the effective max and
|
|
/// repositions the player to its deterministic base spawn slot (<see cref="PlayerSpawnMath"/>). Health.Current
|
|
/// (GhostField) + LocalTransform replicate, so the recovery reaches clients and the derived Dead clears.
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
|
public partial struct PlayerRespawnSystem : ISystem
|
|
{
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
|
if (!serverTick.IsValid)
|
|
return;
|
|
uint now = serverTick.TickIndexForValidTick;
|
|
|
|
// Resilient spawn reference: prefer the BaseAnchor plot center, fall back to the PlayerSpawner. NEVER
|
|
// hard-require PlayerSpawner (a transiently-missing singleton must not strand dead players downed forever).
|
|
bool haveSpawner = SystemAPI.TryGetSingleton<PlayerSpawner>(out var spawner);
|
|
bool haveAnchor = SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor);
|
|
if (!haveSpawner && !haveAnchor)
|
|
return; // no spawn reference at all this tick — re-try next tick rather than mis-place
|
|
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
|
|
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
|
|
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
|
|
center = BaseGridMath.PlotCenter(baseAnchor);
|
|
|
|
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
|
|
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
|
RefRW<RegionTag>, RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
|
|
.WithAll<PlayerTag>())
|
|
{
|
|
if (health.ValueRO.Current > 0f)
|
|
{
|
|
respawn.ValueRW.RespawnTick = 0; // alive: clear any pending schedule
|
|
continue;
|
|
}
|
|
|
|
// Dead this tick.
|
|
if (respawn.ValueRO.RespawnTick == 0)
|
|
{
|
|
// Just died: schedule the recovery.
|
|
respawn.ValueRW.RespawnTick = RespawnMath.RespawnTick(now, respawn.ValueRO.DelayTicks);
|
|
}
|
|
else if (RespawnMath.IsDue(now, respawn.ValueRO.RespawnTick))
|
|
{
|
|
// Recover: full health at the deterministic base spawn slot.
|
|
float maxHealth = eff.ValueRO.MaxHealth > 0f ? eff.ValueRO.MaxHealth : health.ValueRO.Max;
|
|
health.ValueRW.Current = maxHealth;
|
|
|
|
float3 pos = center + PlayerSpawnMath.SpawnOffset(
|
|
owner.ValueRO.NetworkId, ringRadius, ringSlots);
|
|
xform.ValueRW.Position = pos;
|
|
// Death fix: respawn is at BASE, so the player's server-only RegionTag MUST return to Base too
|
|
// (every other mover flips RegionTag + Position together). Dying on an expedition otherwise leaves
|
|
// you at base coords still tagged Expedition -> RegionRelevancy hides all base ghosts (soft-brick).
|
|
region.ValueRW.Region = RegionId.Base;
|
|
|
|
// Grant brief post-respawn damage immunity so the swarm can't instantly re-kill.
|
|
invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks));
|
|
|
|
respawn.ValueRW.RespawnTick = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|