Survival mode bugfixes and revive mechanism
This commit is contained in:
parent
d701598350
commit
2fa85e8b1f
14 changed files with 529 additions and 35 deletions
|
|
@ -49,6 +49,33 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
private double _teamDealMult = 1.0;
|
||||
private double _teamTakeMult = 1.0;
|
||||
|
||||
// ---- Field Medic teammate-revive (card-only: no FieldMedic card => none of this runs) ----
|
||||
private CounterStrikeSharp.API.Modules.Timers.Timer? _reviveTick; // 0.1s heartbeat: proximity detect + channel progress (finer than the 0.5s WaveTick for a smooth bar/locator)
|
||||
private readonly Dictionary<ulong, DownedInfo> _downed = []; // downed player's SteamId -> death spot + world beacon (created only while the squad has a medic)
|
||||
private readonly Dictionary<ulong, ChannelState> _channels = []; // medic's SteamId -> the revive they're currently channeling
|
||||
private readonly HashSet<ulong> _revivedForXpThisWave = []; // victims already paid a support-XP bounty this wave (one per saved teammate; reset at wave start)
|
||||
private const double ReviveEdgeFrac = 0.82; // within this fraction of the radius = fine; beyond it = the amber "about to exit" warn
|
||||
|
||||
// A downed, revivable teammate: the death spot (revive lands them here, next to the reviver) + the world beacon over it.
|
||||
private sealed class DownedInfo
|
||||
{
|
||||
public required Vector Pos { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public bool KilledByBot { get; init; } // combat death (bot kill) vs suicide/fall/world — gates the reviver's XP bounty
|
||||
public CPointWorldText? Beacon; // field, not property, so WorldText.Destroy(ref ...) can null it
|
||||
public string LastText = ""; // last beacon text pushed (skip redundant SetMessage)
|
||||
public void DestroyBeacon() => WorldText.Destroy(ref Beacon);
|
||||
}
|
||||
|
||||
// One medic's in-progress channel (time-based: progress = (now - StartedAt) / channelSeconds, so it's smooth regardless of tick jitter).
|
||||
private struct ChannelState
|
||||
{
|
||||
public ulong TargetSid;
|
||||
public double StartedAt;
|
||||
public bool NearEdge; // drifting toward the radius edge -> HUD amber warn
|
||||
public bool WarnedEdge; // edge warn sound already played this crossing (one-shot)
|
||||
}
|
||||
|
||||
// ---- IMatchDriver: identity ----
|
||||
public string Id => "survival";
|
||||
public IReadOnlyList<string> Maps =>
|
||||
|
|
@ -87,10 +114,32 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
public void OnHeadshotDeath(CCSPlayerController victim) { } // no ladder in survival
|
||||
|
||||
// A human died: no team switch (mp_respawn_on_death_ct 0 keeps them dead = auto death-spectate; revived at wave-clear).
|
||||
// Run wipe detection next frame (count after the pawn is actually down).
|
||||
public void OnHumanDeath(CCSPlayerController victim, PlayerData pd)
|
||||
// Field Medic: if the squad has a medic, mark this body revivable — capture the death spot (same corpse-position read the
|
||||
// explode-on-kill card does in the death hook) and drop a beacon so a teammate can channel a mid-wave revive. Then run
|
||||
// wipe detection next frame (count after the pawn is actually down; a downed-awaiting-revive player counts as not-alive,
|
||||
// so an all-down squad still wipes — you can't revive if nobody's up).
|
||||
public void OnHumanDeath(CCSPlayerController victim, PlayerData pd, CCSPlayerController? attacker)
|
||||
{
|
||||
if (!_runActive) return;
|
||||
// Field Medic: register a revivable beacon, but ONLY for a CT participant (gate on victim.Team==CT, read NOW since
|
||||
// team can change by the deferred frame) — this excludes a bug-2 spectator-park suicide of a mid-run T-joiner, which
|
||||
// would otherwise become a revivable entry that pulls a non-participant onto CT (fail-closed bypass). killedByBot
|
||||
// (captured here) gates the reviver's XP bounty so a suicide/fall can't be farmed. Capture the death spot NOW (a safe
|
||||
// read, like the explode-on-kill card in this Pre hook) but DEFER the beacon ENTITY creation out of the Pre hook.
|
||||
bool killedByBot = OutnumberedPlugin.IsBot(attacker); // PvE: only bots kill humans (FF is off); null/self => not combat
|
||||
if (_phase == WavePhase.Fighting && victim.Team == CsTeam.CounterTerrorist && SquadHasMedic()
|
||||
&& !_downed.ContainsKey(pd.SteamId) && victim.PlayerPawn.Value?.AbsOrigin is { } corpse)
|
||||
{
|
||||
var pos = new Vector(corpse.X, corpse.Y, corpse.Z);
|
||||
string name = victim.PlayerName;
|
||||
ulong sid = pd.SteamId;
|
||||
Server.NextFrame(() =>
|
||||
{
|
||||
// re-check the run is live + they didn't disconnect in the death->frame gap (else a ghost beacon wastes a charge)
|
||||
if (_runActive && _phase == WavePhase.Fighting && !_downed.ContainsKey(sid) && ControllerFor(sid) is not null)
|
||||
_downed[sid] = new DownedInfo { Pos = pos, Name = name, KilledByBot = killedByBot, Beacon = CreateBeacon(pos) };
|
||||
});
|
||||
}
|
||||
Server.NextFrame(() =>
|
||||
{
|
||||
if (_runActive && _phase == WavePhase.Fighting && AliveCtHumans() == 0)
|
||||
|
|
@ -99,8 +148,13 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
}
|
||||
|
||||
// A human left mid-run: every CLEARED wave's XP is already in their main table (granted at each wave clear), so there's
|
||||
// nothing to bank here — the in-progress (uncleared) wave is forfeited. Just forget the run.
|
||||
public void OnHumanDisconnect(ulong steamId, PlayerData pd) => _runs.Remove(steamId);
|
||||
// nothing to bank here — the in-progress (uncleared) wave is forfeited. Drop the run + any downed/channel state they own.
|
||||
public void OnHumanDisconnect(ulong steamId, PlayerData pd)
|
||||
{
|
||||
_runs.Remove(steamId);
|
||||
_channels.Remove(steamId); // if they were mid-channel as a medic
|
||||
if (_downed.Remove(steamId, out var info)) info.DestroyBeacon(); // if they were the one downed
|
||||
}
|
||||
|
||||
// ---- IMatchDriver: cards (EffRun overlay) ----
|
||||
// The active run is the snapshot's card source; null outside a run (Snapshot.Cards then null -> Domain reads 0).
|
||||
|
|
@ -140,11 +194,15 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
// Server.CurrentTime) SIGSEGVs the server. Only SCHEDULE the heartbeat (AddTimer is safe at Load); it stays
|
||||
// gated by _ready until the first map setup arms it. _ready/_mapReadyAt are set in OnMatchReset + OnMapSetup.
|
||||
_tick ??= _p.AddTimer(0.5f, WaveTick, TimerFlags.REPEAT); // the wave state machine heartbeat (also drives bot_quota)
|
||||
_reviveTick ??= _p.AddTimer(0.1f, ReviveTick, TimerFlags.REPEAT); // finer revive-channel heartbeat (proximity + progress)
|
||||
}
|
||||
|
||||
// Plugin Unload / hot-reload: kill the heartbeat so it can't leak or double-fire against a torn-down instance
|
||||
// (a fresh driver schedules its own _tick on the next Load). The framework doesn't reliably auto-kill plugin timers.
|
||||
public void OnDeactivated() { _tick?.Kill(); _tick = null; }
|
||||
// Plugin Unload / hot-reload: kill the heartbeats so they can't leak or double-fire against a torn-down instance
|
||||
// (a fresh driver schedules its own on the next Load), and destroy any live beacon entities. The framework doesn't
|
||||
// reliably auto-kill plugin timers or our entities.
|
||||
// Also drop _runActive/_phase so any Server.NextFrame closure still queued at teardown (beacon-create / wipe-check) fails
|
||||
// its guard against this discarded instance instead of spawning an orphan beacon a fresh driver never learns about.
|
||||
public void OnDeactivated() { _tick?.Kill(); _tick = null; _reviveTick?.Kill(); _reviveTick = null; _runActive = false; _phase = WavePhase.Idle; ClearRevives(); }
|
||||
|
||||
// Called once the map is set up (Driver.SetupMap — covers both normal map start and a hot-reload mid-map), i.e. the
|
||||
// server is simulating and engine access is safe. This is what actually arms the wave machine.
|
||||
|
|
@ -156,7 +214,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
|
||||
public void OnMatchReset()
|
||||
{
|
||||
_runs.Clear(); ResetTeamBuffs();
|
||||
_runs.Clear(); ResetTeamBuffs(); ClearRevives();
|
||||
_phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0;
|
||||
_highestWaveCleared = 0; _runActive = false; _runEnded = false; _phaseUntil = 0;
|
||||
_ready = true; // OnMatchReset only runs on a real map start (server up) -> safe to read the clock + arm
|
||||
|
|
@ -217,6 +275,8 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
_waveBudget = SurvivalEconomy.WaveBudget(n, humans, cfg);
|
||||
_p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}");
|
||||
_p.CloseAllShops(); // nobody frozen in a menu when the wave starts
|
||||
RefreshMedicCharges(); // Field Medic revives refresh each wave (scaled by the medic's card level)
|
||||
_revivedForXpThisWave.Clear(); // support-XP bounties reset per wave (one per saved teammate)
|
||||
UpdateBotQuota();
|
||||
Server.PrintToChatAll($" {ChatColors.Red}[Survival] WAVE {n}/{cfg.WaveCount} {ChatColors.Default}— {ChatColors.LightYellow}{_waveBudget}{ChatColors.Default} hostiles inbound. Hold the line!");
|
||||
}
|
||||
|
|
@ -250,6 +310,7 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
_phase = WavePhase.Break; _phaseUntil = Server.CurrentTime + cfg.WaveBreakSeconds;
|
||||
UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick)
|
||||
ReviveDead();
|
||||
ClearRevives(); // the wave is over: ReviveDead brought back the downed (non-hardcore); in hardcore they're out. Either way the beacons/channels go.
|
||||
GrantCards();
|
||||
Server.PrintToChatAll($" {ChatColors.Green}[Survival] Wave {_wave} cleared! {ChatColors.Default}{cfg.WaveBreakSeconds:0}s to prep — the draft pops up ({ChatColors.Gold}X{ChatColors.Default}/crouch to close).");
|
||||
_p.AddTimer(0.75f, _p.OpenDraftForAll); // auto-pop the card overlay once the revive-spawns have settled
|
||||
|
|
@ -260,13 +321,242 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
_p.LogSurvival($"EndRun: {reason} (win={win}, highestWaveCleared={_highestWaveCleared}, wave={_wave}, kills={_killsThisWave}/{_waveBudget})");
|
||||
// Per-wave XP was already granted into the main table at each ClearWave; an in-progress (uncleared) wave is
|
||||
// forfeited. So there's nothing to bank at run-end — just drop the runs.
|
||||
_runs.Clear();
|
||||
_runs.Clear(); ClearRevives();
|
||||
_runActive = false; _runEnded = true; _phase = WavePhase.Idle; _wave = 0;
|
||||
UpdateBotQuota(); // quota 0
|
||||
Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared}).");
|
||||
_p.TriggerMapEnd(win ? "Survival: VICTORY" : "Survival: run over");
|
||||
}
|
||||
|
||||
// ---- Field Medic teammate-revive: the proximity channel machine ----
|
||||
// Card-only: a downed teammate drops a world beacon; a medic (someone who drafted FieldMedic) who stands within
|
||||
// ReviveRadius channels a revive over a level-scaled time — no keypress, proximity IS the input. Runs on the 0.1s
|
||||
// _reviveTick so the bar/locator are smooth. All downed/beacon state is torn down at wave clear / run end / reset.
|
||||
private readonly List<ulong> _channelReap = []; // reusable scratch for pruning stale channels (avoids a per-tick Keys alloc)
|
||||
private const int BarCells = 10; // progress-bar width in glyph cells
|
||||
private const float BeaconRise = 45f; // how far above the death spot the beacon floats
|
||||
private const double MetersDivisor = 40.0; // units -> "m" for the locator readout (~player height per metre)
|
||||
|
||||
// The medic's FieldMedic card level (picks); 0 = not a medic.
|
||||
private static int MedicLevel(SurvivalRun run) => run.Cards.GetValueOrDefault(CardKeys.FieldMedic);
|
||||
|
||||
// Anyone in the run drafted Field Medic? Gates beacon creation so a no-medic squad gets no useless "down" markers.
|
||||
private bool SquadHasMedic()
|
||||
{
|
||||
foreach (var run in _runs.Values) if (MedicLevel(run) > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Refresh every medic's per-wave revive charges from their card level (called at StartWave; use-them-or-lose-them).
|
||||
private void RefreshMedicCharges()
|
||||
{
|
||||
foreach (var run in _runs.Values)
|
||||
run.ReviveCharges = SurvivalEconomy.ReviveChargesForWave(MedicLevel(run), _p.Config.Survival);
|
||||
}
|
||||
|
||||
// Tear down ALL downed/channel state + destroy every beacon entity (wave clear, run end, match reset, hot-reload).
|
||||
private void ClearRevives()
|
||||
{
|
||||
foreach (var info in _downed.Values) info.DestroyBeacon();
|
||||
_downed.Clear();
|
||||
_channels.Clear();
|
||||
}
|
||||
|
||||
private CPointWorldText? CreateBeacon(Vector pos)
|
||||
{
|
||||
// A world-ANCHORED panel (unlike the eye-relative HUD/shop panels) — squad-visible (no CheckTransmit hiding). The
|
||||
// orientation is refreshed each tick in RefreshBeacons to face the nearest teammate.
|
||||
var ent = WorldText.Create(48f, 0.35f, _p.Config.Hud.FontName,
|
||||
System.Drawing.Color.FromArgb(255, 255, 80, 80), drawBackground: true, border: 0.15f,
|
||||
PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
|
||||
if (ent is not null) WorldText.Place(ent, new Vector(pos.X, pos.Y, pos.Z + BeaconRise), new QAngle(0, 0, 90));
|
||||
return ent;
|
||||
}
|
||||
|
||||
// 0.1s heartbeat: advance every alive medic's proximity channel, then keep the beacons oriented + labelled.
|
||||
private void ReviveTick()
|
||||
{
|
||||
if (!_ready) return;
|
||||
if (!_runActive || _phase != WavePhase.Fighting || _downed.Count == 0)
|
||||
{
|
||||
if (_channels.Count > 0) _channels.Clear(); // no active reviving outside a fighting wave with downed players
|
||||
return;
|
||||
}
|
||||
double now = Server.CurrentTime;
|
||||
foreach (var medic in CtHumans())
|
||||
if (medic.PawnIsAlive) ProcessMedic(medic, now);
|
||||
PruneChannels();
|
||||
RefreshBeacons();
|
||||
}
|
||||
|
||||
// One medic's channel step: nearest downed teammate in range -> (re)start / advance / complete the channel, or drop it.
|
||||
private void ProcessMedic(CCSPlayerController medic, double now)
|
||||
{
|
||||
var pd = _p.PdOf(medic);
|
||||
if (pd is null) return;
|
||||
var pawn = medic.PlayerPawn.Value;
|
||||
if (!_runs.TryGetValue(pd.SteamId, out var run) || run.ReviveCharges <= 0 || MedicLevel(run) <= 0
|
||||
|| pawn?.AbsOrigin is not { } mpos)
|
||||
{ _channels.Remove(pd.SteamId); return; }
|
||||
|
||||
double radius = _p.Config.Survival.ReviveRadius, r2 = radius * radius;
|
||||
var (best, bestD2) = NearestDowned(mpos, pd.SteamId);
|
||||
if (best == 0 || bestD2 > r2) { _channels.Remove(pd.SteamId); return; } // nobody in range -> the channel drops
|
||||
|
||||
if (!_channels.TryGetValue(pd.SteamId, out var ch) || ch.TargetSid != best)
|
||||
ch = new ChannelState { TargetSid = best, StartedAt = now }; // new target -> restart the channel
|
||||
ch.NearEdge = bestD2 > r2 * ReviveEdgeFrac * ReviveEdgeFrac;
|
||||
if (ch.NearEdge && !ch.WarnedEdge) { _p.PlayReviveWarn(medic); ch.WarnedEdge = true; } // one-shot edge beep
|
||||
else if (!ch.NearEdge) ch.WarnedEdge = false;
|
||||
_channels[pd.SteamId] = ch;
|
||||
|
||||
double dur = SurvivalEconomy.ReviveChannelSeconds(MedicLevel(run), _p.Config.Survival);
|
||||
if (dur <= 0 || now - ch.StartedAt >= dur) CompleteRevive(medic, pd, run, best);
|
||||
}
|
||||
|
||||
private void CompleteRevive(CCSPlayerController medic, PlayerData medicPd, SurvivalRun run, ulong targetSid)
|
||||
{
|
||||
_channels.Remove(medicPd.SteamId);
|
||||
if (!_downed.Remove(targetSid, out var info)) return; // someone else revived them this tick -> don't spend a charge
|
||||
run.ReviveCharges--;
|
||||
double hpPct = SurvivalEconomy.ReviveHpFraction(MedicLevel(run), _p.Config.Survival);
|
||||
var target = ControllerFor(targetSid);
|
||||
// Land them on the REVIVER's spot (guaranteed-walkable ground the medic is standing on), not the raw death spot —
|
||||
// which could be a hazard/kill-volume that would just re-drop and re-kill them, burning charges for nothing.
|
||||
var landing = medic.PlayerPawn.Value?.AbsOrigin is { } mo ? new Vector(mo.X, mo.Y, mo.Z) : info.Pos;
|
||||
_p.ReviveDownedAt(target, landing, hpPct); // respawn + teleport beside the reviver + set HP
|
||||
info.DestroyBeacon();
|
||||
_p.PlayReviveComplete(medic);
|
||||
// Support-XP: a FLAT bounty (NOT routed through WaveXp, so it never rides the x24 wave multiplier), and ONLY for a
|
||||
// genuine COMBAT death, ONCE per saved teammate per wave — rewards saving allies, not a death<->revive farm loop.
|
||||
if (info.KilledByBot && _revivedForXpThisWave.Add(targetSid)) _p.GrantReviveXp(medicPd, medic);
|
||||
string tname = target?.PlayerName ?? info.Name;
|
||||
Server.PrintToChatAll($" {ChatColors.Green}[Survival] {ChatColors.Lime}{medic.PlayerName} {ChatColors.Default}revived {ChatColors.Lime}{tname}{ChatColors.Default}!");
|
||||
_p.LogSurvival($"revive: {medic.PlayerName} -> {tname} @ {hpPct:P0} ({run.ReviveCharges} charge(s) left)");
|
||||
}
|
||||
|
||||
// Drop channel state for medics who died / disconnected since the last tick (so a dead medic's channel can't keep a
|
||||
// beacon falsely reading "reviving"). ProcessMedic already drops channels for out-of-range / out-of-charge medics.
|
||||
private void PruneChannels()
|
||||
{
|
||||
if (_channels.Count == 0) return;
|
||||
_channelReap.Clear();
|
||||
foreach (var sid in _channels.Keys)
|
||||
{
|
||||
var c = ControllerFor(sid);
|
||||
if (c is null || !c.PawnIsAlive) _channelReap.Add(sid);
|
||||
}
|
||||
foreach (var sid in _channelReap) _channels.Remove(sid);
|
||||
}
|
||||
|
||||
// Keep each beacon facing the nearest teammate (mirrors the HUD's face-the-viewer angle convention) + reflect whether
|
||||
// it's actively being revived. Beacons are shared entities, so they can only face ONE viewer — the nearest is the bet.
|
||||
private void RefreshBeacons()
|
||||
{
|
||||
foreach (var kv in _downed)
|
||||
{
|
||||
var info = kv.Value;
|
||||
if (info.Beacon is not { IsValid: true } b) continue;
|
||||
var top = new Vector(info.Pos.X, info.Pos.Y, info.Pos.Z + BeaconRise);
|
||||
// face the nearest teammate — the +270 convention is single-sourced in WorldText.FacePointAngle.
|
||||
if (NearestAliveHuman(info.Pos)?.PlayerPawn.Value?.AbsOrigin is { } vp)
|
||||
WorldText.Place(b, top, WorldText.FacePointAngle(top, vp));
|
||||
string txt = ChannelTargets(kv.Key) ? $"⚕ {info.Name} — reviving…" : $"⚕ {info.Name}";
|
||||
if (info.LastText != txt) { WorldText.SetText(b, txt); info.LastText = txt; }
|
||||
}
|
||||
}
|
||||
|
||||
private bool ChannelTargets(ulong downedSid)
|
||||
{
|
||||
foreach (var c in _channels.Values) if (c.TargetSid == downedSid) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The per-player HUD line (IMatchDriver): channel bar while reviving, edge warn while drifting, else the locator to the
|
||||
// nearest downed teammate. "" for non-medics / no downed / out of charges. html = center-HTML (font tags) vs world text.
|
||||
public string ReviveHudLine(CCSPlayerController p, PlayerData pd, bool html)
|
||||
{
|
||||
if (!_runActive || _phase != WavePhase.Fighting || _downed.Count == 0) return "";
|
||||
if (!_runs.TryGetValue(pd.SteamId, out var run) || MedicLevel(run) <= 0) return ""; // not a medic
|
||||
|
||||
// channeling right now -> the bar (or the edge warning)
|
||||
if (_channels.TryGetValue(pd.SteamId, out var ch) && _downed.TryGetValue(ch.TargetSid, out var tgt))
|
||||
{
|
||||
if (ch.NearEdge) return WarnLine(html);
|
||||
double dur = SurvivalEconomy.ReviveChannelSeconds(MedicLevel(run), _p.Config.Survival);
|
||||
double prog = dur <= 0 ? 1.0 : Math.Clamp((Server.CurrentTime - ch.StartedAt) / dur, 0.0, 1.0);
|
||||
return BarLine(tgt.Name, prog, html);
|
||||
}
|
||||
if (run.ReviveCharges <= 0) return ""; // no charges left this wave -> nothing to prompt
|
||||
|
||||
// otherwise: locate the nearest downed teammate (arrow + distance)
|
||||
if (p.PlayerPawn.Value is not { } pawn || pawn.AbsOrigin is not { } mpos) return "";
|
||||
var (best, bestD2) = NearestDowned(mpos, pd.SteamId);
|
||||
if (best == 0) return "";
|
||||
var info = _downed[best];
|
||||
int meters = SurvivalEconomy.LocatorMeters(Math.Sqrt(bestD2), MetersDivisor);
|
||||
return LocatorLine(info.Name, CompassArrow(pawn, info.Pos), meters, html);
|
||||
}
|
||||
|
||||
private static string BarLine(string name, double prog, bool html)
|
||||
{
|
||||
int filled = (int)Math.Round(Math.Clamp(prog, 0.0, 1.0) * BarCells);
|
||||
string bar = new string('█', filled) + new string('░', BarCells - filled); // █ / ░
|
||||
int pct = (int)Math.Round(prog * 100);
|
||||
return html
|
||||
? $"<font color='#7dff7d'>⚕ Reviving {Esc(name)} {bar} {pct}%</font>"
|
||||
: $"⚕ Reviving {name} {bar} {pct}%";
|
||||
}
|
||||
|
||||
private static string WarnLine(bool html) =>
|
||||
html ? "<font color='#ffb000'>⚠ hold position — revive slipping</font>"
|
||||
: "⚠ hold position — revive slipping";
|
||||
|
||||
private static string LocatorLine(string name, string arrow, int meters, bool html) =>
|
||||
html ? $"<font color='#66ddff'>⚕ {Esc(name)} down {arrow} {meters}m</font>"
|
||||
: $"⚕ {name} down {arrow} {meters}m";
|
||||
|
||||
// 8-way compass arrow from the medic's view toward a target. The bearing->sector math is pure + golden-tested in
|
||||
// SurvivalEconomy.ArrowIndex; this wrapper only computes the live bearing from the pawn. (If left/right read mirrored
|
||||
// in-engine, negate the (bearing - viewYaw) term.)
|
||||
private static string CompassArrow(CCSPlayerPawn pawn, Vector target)
|
||||
{
|
||||
var o = pawn.AbsOrigin;
|
||||
if (o is null) return "";
|
||||
double bearing = Math.Atan2(target.Y - o.Y, target.X - o.X) * 180.0 / Math.PI;
|
||||
string[] arrows = { "↑", "↖", "←", "↙", "↓", "↘", "→", "↗" }; // ↑ ↖ ← ↙ ↓ ↘ → ↗
|
||||
return arrows[SurvivalEconomy.ArrowIndex(bearing - pawn.EyeAngles.Y)];
|
||||
}
|
||||
|
||||
private static double Dist2(Vector a, Vector b) { double dx = a.X - b.X, dy = a.Y - b.Y, dz = a.Z - b.Z; return dx * dx + dy * dy + dz * dz; }
|
||||
|
||||
// Nearest downed teammate to `pos` (excluding `exceptSid` — you can't revive yourself); (0, +inf) if none.
|
||||
// Shared by the channel machine (ProcessMedic) and the HUD locator so the "closest body" pick can't drift between them.
|
||||
private (ulong sid, double dist2) NearestDowned(Vector pos, ulong exceptSid)
|
||||
{
|
||||
ulong best = 0; double bestD2 = double.MaxValue;
|
||||
foreach (var kv in _downed)
|
||||
{
|
||||
if (kv.Key == exceptSid) continue;
|
||||
double d2 = Dist2(pos, kv.Value.Pos);
|
||||
if (d2 < bestD2) { bestD2 = d2; best = kv.Key; }
|
||||
}
|
||||
return (best, bestD2);
|
||||
}
|
||||
private static string Esc(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
private static CCSPlayerController? NearestAliveHuman(Vector pos)
|
||||
{
|
||||
CCSPlayerController? best = null; double bestD2 = double.MaxValue;
|
||||
foreach (var h in CtHumans())
|
||||
if (h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is { } o)
|
||||
{
|
||||
double d2 = Dist2(pos, new Vector(o.X, o.Y, o.Z));
|
||||
if (d2 < bestD2) { bestD2 = d2; best = h; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ---- bots: vanilla, count-throttled via bot_quota ----
|
||||
public void ManageBots()
|
||||
{
|
||||
|
|
@ -450,6 +740,12 @@ public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
|||
var cfg = _p.Config.Survival;
|
||||
return $"{cfg.BurnDamagePerSecond:0} HP/s for {cfg.BurnDurationSeconds:0}s";
|
||||
}
|
||||
// Field Medic scales off the Revive* knobs (charges/wave, channel time, revive HP%), not the card PerPick.
|
||||
if (key == CardKeys.FieldMedic)
|
||||
{
|
||||
var cfg = _p.Config.Survival;
|
||||
return $"revive allies · {SurvivalEconomy.ReviveChargesForWave(lvl, cfg)}/wave · {SurvivalEconomy.ReviveChannelSeconds(lvl, cfg):0.0}s · {SurvivalEconomy.ReviveHpFraction(lvl, cfg) * 100:0}% HP";
|
||||
}
|
||||
// Everything else: the data-driven Detail template ({0} = level x PerPick). Empty -> stat card -> Now/Next % panel.
|
||||
return string.IsNullOrEmpty(def.Detail) ? null : string.Format(def.Detail, add);
|
||||
}
|
||||
|
|
@ -550,24 +846,46 @@ public sealed partial class OutnumberedPlugin
|
|||
p?.PrintToChat($" {ChatColors.Gold}[Survival] Wave {wave} XP: {ChatColors.Lime}+{lump:n0} {ChatColors.Default}(x{SurvivalEconomy.WaveMult(wave, cfg):F1}).");
|
||||
}
|
||||
|
||||
// Revive a downed survivor at the configured HP% (used between waves). mp_respawn_on_death_ct 0 keeps them down,
|
||||
// so this is a manual Respawn; they're still on CT (never moved to spectator), so no team churn.
|
||||
internal void ReviveSurvivor(CCSPlayerController p)
|
||||
// Shared revive core: respawn a dead CT ally, then (deferred, slot+SteamID-pinned so a within-frame slot-reuse can't hit
|
||||
// the wrong player) optionally teleport them + set HP to hpPct of their max. mp_respawn_on_death_ct 0 keeps them down, so
|
||||
// Respawn is manual. CT-only by construction (a non-CT target is refused, not force-switched) so a stray non-participant
|
||||
// entry can never be pulled onto CT. The two revive flavors below own only their guard/pct/teleport specifics.
|
||||
private void ReviveAt(CCSPlayerController p, double hpPct, Vector? teleportTo)
|
||||
{
|
||||
if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
|
||||
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
||||
double pct = Config.Survival.ReviveHpPercent;
|
||||
var dest = teleportTo is { } t ? new Vector(t.X, t.Y, t.Z) : null;
|
||||
p.Respawn();
|
||||
// Defer through the seam: re-resolve by slot + pin SteamID so a within-frame slot-reuse can't revive a different player.
|
||||
NextFrameForSlot(p.Slot, sid, pl =>
|
||||
{
|
||||
var pd = PdOf(pl); if (pd is null) return;
|
||||
var pd = PdOf(pl); var pawn = pl.PlayerPawn.Value;
|
||||
if (pd is null || pawn is null) return;
|
||||
if (dest is not null) pawn.Teleport(dest, null, new Vector(0, 0, 0));
|
||||
ApplyMaxHpArmor(pl, pd);
|
||||
var pawn = pl.PlayerPawn.Value;
|
||||
if (pawn is not null) PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * pct)));
|
||||
PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * hpPct)));
|
||||
}, requireAlive: true);
|
||||
}
|
||||
|
||||
// The automatic wave-clear revive: respawn in place (at a spawn point), configured HP%.
|
||||
internal void ReviveSurvivor(CCSPlayerController p) => ReviveAt(p, Config.Survival.ReviveHpPercent, null);
|
||||
|
||||
// The Field Medic teammate-revive: respawn + teleport beside the reviver at a medic-scaled HP%. No-op if the target left.
|
||||
internal void ReviveDownedAt(CCSPlayerController? p, Vector pos, double hpPct)
|
||||
{
|
||||
if (p is not null) ReviveAt(p, hpPct, pos);
|
||||
}
|
||||
|
||||
// A FLAT support-XP bounty for a Field Medic revive: straight into the main table (NOT the per-wave accumulator, so it
|
||||
// never rides the wave multiplier), prestige/handicap excluded — a fixed reward for saving a teammate, not a farm.
|
||||
internal void GrantReviveXp(PlayerData pd, CCSPlayerController medic)
|
||||
{
|
||||
long lump = (long)Config.Survival.ReviveXp;
|
||||
if (lump > 0) AddConvertedXp(pd, lump, medic);
|
||||
}
|
||||
|
||||
internal void PlayReviveComplete(CCSPlayerController p) => PlaySound(p, Config.Sounds.ReviveComplete);
|
||||
internal void PlayReviveWarn(CCSPlayerController p) => PlaySound(p, Config.Sounds.ReviveWarn);
|
||||
|
||||
// A survival Max-HP / Max-Armor card just took effect: raise the live cap and top current HP/armor up by the card's
|
||||
// bonus ONLY — never a full heal (a full heal on any pick would erase the 50% revive penalty and reset the squad to
|
||||
// full HP every wave). Other cards never touch current HP/armor.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue