initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
590
Outnumbered/Shop.cs
Normal file
590
Outnumbered/Shop.cs
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
using System.Text;
|
||||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Modules.Utils;
|
||||
using Outnumbered.Data;
|
||||
using Outnumbered.Engine;
|
||||
|
||||
namespace Outnumbered;
|
||||
|
||||
// The quick-buy shop. Opened by switching to the healthshot carrier (X); while open the player is
|
||||
// frozen (MoveType NONE) and the menu is two world-text panels in front of the camera (left = shop options,
|
||||
// right = info), each hugging the centre. Inputs are weapon-slot / grenade key presses, detected as
|
||||
// active-weapon changes (the same trick the abilities use):
|
||||
// * keys 1/2 = primary / secondary slots
|
||||
// * key 3 = the zeus (shares the melee slot with the knife; pressing 3 flips knife->zeus = a detectable switch)
|
||||
// * keys 6/7/8/9/0 = the five grenades (GIVEN on open so the slots are populated and the presses register)
|
||||
// * X (healthshot) = back one screen, or close at root
|
||||
// Between presses the player rests on the KNIFE (the neutral, slot 3) so repeated keys (e.g. X-1-6-6) each
|
||||
// produce a fresh switch event. Resetting to the knife keeps a grenade from being held long enough to throw,
|
||||
// and the healthshot from being held long enough to heal.
|
||||
public sealed partial class OutnumberedPlugin
|
||||
{
|
||||
private const string ShopRestCmd = Inventory.SlotMelee; // reset between presses = back to the neutral (melee slot)
|
||||
// (the shop "carrier" item names — weapon_healthshot / weapon_taser — live in EngineNames.ShopCarrier/ShopMelee)
|
||||
// The melee slot holds knife + zeus, so ShopRestCmd toggles between them, and the client's switch is observed a network
|
||||
// round-trip later — firing it every tick piles up a toggle backlog. Debounce it so each switch settles (lands on the
|
||||
// knife, where no further rest fires) before the next.
|
||||
private const double RestDebounceSeconds = 0.20;
|
||||
|
||||
// The grenade tail of Weapons.MenuKeys ({1,2,3,6,7,8,9,0}) — the 6/7/8/9/0 keys. Used by BOTH the skills-only
|
||||
// (Gun Game) shop, where OpenShop always populates these so the menu works even when the player holds only one
|
||||
// ladder gun (slots 1/2 may be empty by rung), AND the survival card draft. Not Gun-Game-specific; keep aligned.
|
||||
private static readonly char[] GrenadeMenuKeys = { '6', '7', '8', '9', '0' };
|
||||
private char ShopOptKey(int pos) => _driver.WeaponShopEnabled
|
||||
? MenuKey(pos)
|
||||
: (pos >= 0 && pos < GrenadeMenuKeys.Length ? GrenadeMenuKeys[pos] : '?');
|
||||
|
||||
// The grenade quick-buy keys: menu key -> (grenade weapon used to populate the slot, display label). ONE source for
|
||||
// the slot-population (OpenShop/CloseShop), the WeaponToKey reverse lookup, and the [HE]/[Flash]/... labels — so the
|
||||
// 6=HE alignment isn't re-typed across the file. (Distinct from the JSON-tunable Abilities.AbilityGrenades.)
|
||||
private static readonly (char Key, string Weapon, string Label)[] GrenadeMenu =
|
||||
{
|
||||
('6', "weapon_hegrenade", "HE"),
|
||||
('7', "weapon_flashbang", "Flash"),
|
||||
('8', "weapon_smokegrenade", "Smoke"),
|
||||
('9', "weapon_decoy", "Decoy"),
|
||||
('0', "weapon_incgrenade", "Molotov"),
|
||||
};
|
||||
|
||||
private static readonly string[] SkillGroupNames = { "Damage", "Defense", "Utility" };
|
||||
// SkillGroupStats (the canonical key->group projection) lives in Stats.cs next to StatRegistry; SkillGroupNames[i] labels group i.
|
||||
|
||||
private enum ShopScreen { Root, WeaponCats, Category, SkillGroups, SkillStats, CardDraft }
|
||||
|
||||
private sealed record ShopOption(char Key, string Label, bool Enabled, Action Act);
|
||||
|
||||
private sealed class ShopSession
|
||||
{
|
||||
public bool Open;
|
||||
public ShopScreen Screen;
|
||||
public int CatIndex; // which weapon category (Category screen)
|
||||
public int GroupIndex; // which skill group (SkillStats screen)
|
||||
public bool CarrierWasActive; // rising-edge guard: one X press = one back/close
|
||||
public bool SawNeutral; // have we rested on the knife since opening? (gates the first input)
|
||||
public string LastActive = ""; // last option weapon acted on (de-dupes the deploy-lag ticks)
|
||||
public CPointWorldText? OptionsEntity; // left panel: shop options (mirrors InfoEntity)
|
||||
public CPointWorldText? InfoEntity; // right panel: info
|
||||
public string? LastText;
|
||||
public string? LastInfoText;
|
||||
public readonly CPointWorldText?[] Cards = new CPointWorldText?[3]; // survival draft: the 3 card panels
|
||||
public readonly string?[] CardText = new string?[3]; // last text per card (skip redundant SetMessage)
|
||||
public MoveType_t SavedMoveType = MoveType_t.MOVETYPE_WALK;
|
||||
public double ClosedAt = -999; // Server.CurrentTime at last close (post-close input grace)
|
||||
public double RestAt = -999; // Server.CurrentTime of the last rest command (debounces the slot-select + gates picks until the weapon settles)
|
||||
|
||||
// Remove + null every world-text panel this session owns (the 2 shop panels + the 3 draft cards) and clear their
|
||||
// last-text caches. One teardown for CloseShop + OnClientDisconnect (panels + draft) so no site forgets a panel.
|
||||
public void DestroyEntities()
|
||||
{
|
||||
WorldText.Destroy(ref OptionsEntity);
|
||||
WorldText.Destroy(ref InfoEntity);
|
||||
LastText = null; LastInfoText = null;
|
||||
for (int i = 0; i < Cards.Length; i++) { WorldText.Destroy(ref Cards[i]); CardText[i] = null; }
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Dictionary<int, ShopSession> _shop = new();
|
||||
private readonly List<int> _shopReap = new(); // reused orphan-slot scratch for the per-tick session reap
|
||||
private Action<int>? _shopReapAction; // cached OnClientDisconnect_Shop delegate (so ReapOrphanSlots adds no per-tick closure)
|
||||
|
||||
private const double ShopCloseGrace = 0.35; // suppress ability input this long after close (trailing key-spam)
|
||||
|
||||
// Read by the abilities loop: true while the shop is open OR within the post-close grace, so a 6-spam to max a
|
||||
// skill (key 6 is also a grenade) can't leak into a No Reload cast the instant the menu closes.
|
||||
public bool ShopInputLocked(int slot) =>
|
||||
_shop.TryGetValue(slot, out var s) && (s.Open || Server.CurrentTime - s.ClosedAt < ShopCloseGrace);
|
||||
|
||||
private ShopSession ShopOf(int slot)
|
||||
{
|
||||
if (!_shop.TryGetValue(slot, out var s)) { s = new ShopSession(); _shop[slot] = s; }
|
||||
return s;
|
||||
}
|
||||
|
||||
private static bool IsNeutralMelee(string n) => n == EngineNames.WeaponKnife || n.Contains("knife") || n.Contains("bayonet");
|
||||
|
||||
private void Initialize_Shop()
|
||||
{
|
||||
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Shop is invoked from there.
|
||||
RegisterListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
|
||||
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
|
||||
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Shop);
|
||||
}
|
||||
|
||||
private void Shutdown_Shop()
|
||||
{
|
||||
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
|
||||
RemoveListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
|
||||
foreach (var slot in _shop.Keys.ToList()) CloseShop(slot, switchAway: false);
|
||||
_shop.Clear();
|
||||
}
|
||||
|
||||
// Disconnect can leave a frozen pawn + open session through the reconnect grace window — clear it outright.
|
||||
private void OnClientDisconnect_Shop(int slot)
|
||||
{
|
||||
if (!_shop.TryGetValue(slot, out var s)) return;
|
||||
s.DestroyEntities();
|
||||
var pawn = Utilities.GetPlayerFromSlot(slot)?.PlayerPawn.Value;
|
||||
if (s.Open && pawn is not null && pawn.Health > 0) SetFrozen(pawn, false, s.SavedMoveType);
|
||||
_shop.Remove(slot); // a reconnecting player on this slot starts fresh
|
||||
}
|
||||
|
||||
// Force the menu closed on (re)spawn so a reconnect never resumes mid-menu.
|
||||
private HookResult OnPlayerSpawn_Shop(EventPlayerSpawn ev, GameEventInfo info)
|
||||
{
|
||||
var p = ev.Userid;
|
||||
if (p is { IsValid: true } && !p.IsBot) CloseShop(p.Slot, switchAway: false);
|
||||
return HookResult.Continue;
|
||||
}
|
||||
|
||||
// ---- per-tick input + render ----
|
||||
private void OnTick_Shop(List<CCSPlayerController> players)
|
||||
{
|
||||
if (!Config.Shop.Enabled)
|
||||
{
|
||||
foreach (var (slot, s) in _shop) if (s.Open) CloseShop(slot, switchAway: true); // close out if disabled live
|
||||
return;
|
||||
}
|
||||
|
||||
// Reap sessions whose controller vanished WITHOUT a clean OnClientDisconnect (map change / bot-replaces-human /
|
||||
// kick race) — full cleanup (panels + unfreeze + drop session), not just the open ones. Shared collect-then-act
|
||||
// helper (OnClientDisconnect_Shop mutates _shop). Normal path: no orphans -> no-op.
|
||||
ReapOrphanSlots(_shop, _shopReap, _shopReapAction ??= OnClientDisconnect_Shop);
|
||||
|
||||
foreach (var p in players) HandleShopTick(p);
|
||||
}
|
||||
|
||||
// Reset to the neutral, debounced (see RestDebounceSeconds) so the slot-select toggle settles between fires.
|
||||
private static void Rest(CCSPlayerController p, ShopSession s)
|
||||
{
|
||||
if (Server.CurrentTime - s.RestAt < RestDebounceSeconds) return;
|
||||
p.ExecuteClientCommand(ShopRestCmd);
|
||||
s.RestAt = Server.CurrentTime;
|
||||
}
|
||||
|
||||
// Per-player shop tick: the carrier/menu input state machine (open/back/close, the knife-neutral rest, key->option,
|
||||
// panel render). Split out of OnTick_Shop so that method stays orchestration (disabled-gate + orphan reap + fan-out);
|
||||
// the loop's `continue` becomes `return` here.
|
||||
private void HandleShopTick(CCSPlayerController p)
|
||||
{
|
||||
if (!IsHuman(p)) return; // inline predicate (no Where-iterator alloc on this per-tick path)
|
||||
var s = ShopOf(p.Slot);
|
||||
var pawn = p.PlayerPawn.Value;
|
||||
if (pawn is null || !p.PawnIsAlive)
|
||||
{
|
||||
if (s.Open) CloseShop(p.Slot, switchAway: false);
|
||||
s.CarrierWasActive = false; s.LastActive = "";
|
||||
return;
|
||||
}
|
||||
|
||||
string activeName = Inventory.ActiveWeaponName(pawn);
|
||||
bool carrierActive = activeName == EngineNames.ShopCarrier;
|
||||
|
||||
// X (healthshot) — rising edge: open, or back-one-screen / close at root
|
||||
if (carrierActive && !s.CarrierWasActive)
|
||||
{
|
||||
if (s.Open) Back(p, s);
|
||||
else OpenShop(p, pawn);
|
||||
}
|
||||
s.CarrierWasActive = carrierActive;
|
||||
|
||||
if (!s.Open) return;
|
||||
|
||||
var vel = pawn.AbsVelocity; vel.X = vel.Y = vel.Z = 0f; // hold the freeze
|
||||
|
||||
if ((p.Buttons & PlayerButtons.Duck) != 0) { CloseShop(p.Slot, switchAway: true); return; } // crouch = quick exit
|
||||
|
||||
bool neutralMelee = IsNeutralMelee(activeName);
|
||||
if (neutralMelee) s.SawNeutral = true; // rested on the knife -> input is now live
|
||||
if (carrierActive || neutralMelee)
|
||||
{
|
||||
s.LastActive = ""; // resting (on the carrier mid-toggle, or the knife neutral) — ready for the next press
|
||||
}
|
||||
else
|
||||
{
|
||||
char key = WeaponToKey(activeName);
|
||||
// Accept a pick only once the weapon has SETTLED back on the neutral: not yet rested on the knife since
|
||||
// opening (!SawNeutral), or a rest fired within the debounce window (the slot-select toggle / a re-deploying
|
||||
// weapon is still in flight), means input isn't trustworthy yet — rest and wait. No fixed time grace; the
|
||||
// settle itself is the spacing, so picks register as fast as the weapon can return to neutral (instabuy).
|
||||
if (key == '\0' || !s.SawNeutral || Server.CurrentTime - s.RestAt < RestDebounceSeconds)
|
||||
{
|
||||
Rest(p, s);
|
||||
}
|
||||
else if (activeName != s.LastActive)
|
||||
{
|
||||
s.LastActive = activeName;
|
||||
ExecuteOption(p, s, key);
|
||||
// Rest to the neutral; the settle gate above re-suppresses input until that rest lands, so the
|
||||
// re-deploying weapon can't register as the next pick.
|
||||
if (s.Open) Rest(p, s);
|
||||
}
|
||||
}
|
||||
|
||||
if (s.Open) UpdateShopPanels(p, pawn, s);
|
||||
}
|
||||
|
||||
// active weapon -> the menu key it represents ('\0' = not an option / it's the knife neutral or the carrier)
|
||||
private char WeaponToKey(string active)
|
||||
{
|
||||
if (active.Length == 0) return '\0';
|
||||
if (active == EngineNames.ShopMelee) return '3'; // zeus = key 3 (co-melee with the knife)
|
||||
if (IsNeutralMelee(active)) return '\0'; // knife = neutral rest, not an option
|
||||
if (IsPistolWeapon(active)) return '2';
|
||||
if (IsPrimaryWeapon(active)) return '1';
|
||||
foreach (var g in GrenadeMenu) if (g.Weapon == active) return g.Key;
|
||||
return '\0';
|
||||
}
|
||||
|
||||
// ---- open / close / back ----
|
||||
private void OpenShop(CCSPlayerController p, CCSPlayerPawn pawn)
|
||||
{
|
||||
var s = ShopOf(p.Slot);
|
||||
// Survival between-wave draft takes priority; otherwise skills-only modes skip the root and open on the groups.
|
||||
ShopScreen initial = (Draft is { } sd && sd.DraftPending(p)) ? ShopScreen.CardDraft
|
||||
: _driver.WeaponShopEnabled ? ShopScreen.Root : ShopScreen.SkillGroups;
|
||||
s.Open = true; s.Screen = initial; s.LastActive = "";
|
||||
s.SawNeutral = false; // ignore key input until the player has rested on the knife once (kills the on-open misfire)
|
||||
s.SavedMoveType = pawn.MoveType;
|
||||
SetFrozen(pawn, true);
|
||||
foreach (var g in GrenadeMenu) p.GiveNamedItem(g.Weapon); // populate 6-0 so those keys fire
|
||||
Rest(p, s); // rest on the knife (neutral)
|
||||
s.LastText = null; s.LastInfoText = null;
|
||||
// The draft is a distinct 3-card overlay (panels created lazily in UpdateDraftPanels); the normal shop is the 2-panel layout.
|
||||
if (initial == ShopScreen.CardDraft)
|
||||
p.PrintToChat($" {ChatColors.Gold}[Draft] pick a card — switch to the shown item, X / crouch to close (unspent picks carry over).");
|
||||
else
|
||||
{
|
||||
s.OptionsEntity = CreateShopPanel(true, Config.Shop.FontSize, Config.Shop.WorldUnitsPerPx); // left: shop options
|
||||
s.InfoEntity = CreateShopPanel(false, Config.Shop.InfoFontSize, Config.Shop.InfoWorldUnitsPerPx); // right: info (smaller)
|
||||
p.PrintToChat($" {ChatColors.Gold}[Shop] open — numbers select, X = back / close.");
|
||||
}
|
||||
}
|
||||
|
||||
// Close every open shop/draft (used by survival at wave start so nobody is left frozen in a menu).
|
||||
internal void CloseAllShops()
|
||||
{
|
||||
foreach (var slot in _shop.Keys.ToList()) CloseShop(slot, switchAway: true);
|
||||
}
|
||||
|
||||
// Auto-pop the survival draft for everyone alive on CT with pending picks. Called shortly AFTER wave-clear (so the
|
||||
// revive-spawns have already fired their menu-close), opening straight into the 3-card overlay. Closeable via X / crouch.
|
||||
internal void OpenDraftForAll()
|
||||
{
|
||||
if (!_live) return; // auto-pop is scheduled ~0.75s after wave-clear; skip if unloaded since
|
||||
if (Draft is not { } sd) return;
|
||||
foreach (var p in Utilities.GetPlayers())
|
||||
{
|
||||
if (!IsLiveHuman(p)) continue;
|
||||
if (!sd.DraftPending(p)) continue;
|
||||
var s = ShopOf(p.Slot);
|
||||
if (s.Open) continue;
|
||||
var pawn = p.PlayerPawn.Value;
|
||||
if (pawn is not null) OpenShop(p, pawn); // DraftPending -> OpenShop selects the CardDraft screen
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseShop(int slot, bool switchAway)
|
||||
{
|
||||
if (!_shop.TryGetValue(slot, out var s)) return;
|
||||
bool wasOpen = s.Open;
|
||||
s.Open = false; s.LastActive = "";
|
||||
if (wasOpen) s.ClosedAt = Server.CurrentTime; // start the post-close input grace
|
||||
s.DestroyEntities();
|
||||
if (!wasOpen) return;
|
||||
|
||||
var p = Utilities.GetPlayerFromSlot(slot);
|
||||
if (p is { IsValid: true })
|
||||
foreach (var g in GrenadeMenu) p.RemoveItemByDesignerName(g.Weapon); // ability reconcile re-grants real ones
|
||||
var pawn = p?.PlayerPawn.Value;
|
||||
if (pawn is not null && pawn.Health > 0) SetFrozen(pawn, false, s.SavedMoveType);
|
||||
// Switch to the gun the player actually has, not a hardcoded slot1 — in Gun Game the rung weapon may be a
|
||||
// pistol (slot2) or, on the knife finale, only the knife (slot3); slot1 would otherwise strand them on the knife.
|
||||
if (switchAway && p is { IsValid: true }) p.ExecuteClientCommand(PreferredSlotCmd(p));
|
||||
}
|
||||
|
||||
// The client command to rest on the best NON-grenade weapon the player holds: primary, else pistol, else the
|
||||
// zeus, else the knife. The zeus is preferred over the knife as the rest because the engine auto-deploys granted
|
||||
// ability grenades over the KNIFE (the GG knife-rung perk bug) but not over a real weapon like the zeus.
|
||||
private string PreferredSlotCmd(CCSPlayerController p)
|
||||
{
|
||||
// Classify all held weapons in ONE pass; keep the no-WeaponServices -> slot1 fallback (distinct from
|
||||
// "has weapons but no primary" -> slot3). The inner valid-weapon walk is shared via Inventory.Weapons.
|
||||
if (!Inventory.HasWeaponServices(p)) return Inventory.SlotPrimary;
|
||||
bool primary = false, secondary = false, zeus = false;
|
||||
foreach (var w in Inventory.Weapons(p))
|
||||
{
|
||||
string n = w.DesignerName;
|
||||
if (n == EngineNames.ShopMelee) zeus = true;
|
||||
else if (IsPrimaryWeapon(n)) primary = true;
|
||||
else if (IsPistolWeapon(n)) secondary = true;
|
||||
}
|
||||
return primary ? Inventory.SlotPrimary : secondary ? Inventory.SlotSecondary : zeus ? Inventory.UseZeus : Inventory.SlotMelee;
|
||||
}
|
||||
|
||||
private void Back(CCSPlayerController p, ShopSession s)
|
||||
{
|
||||
switch (s.Screen)
|
||||
{
|
||||
case ShopScreen.WeaponCats:
|
||||
s.Screen = ShopScreen.Root; s.LastActive = ""; Rest(p, s); break;
|
||||
case ShopScreen.SkillGroups:
|
||||
if (_driver.WeaponShopEnabled) { s.Screen = ShopScreen.Root; s.LastActive = ""; Rest(p, s); }
|
||||
else CloseShop(p.Slot, switchAway: true); // skills-only: SkillGroups is home -> back = close
|
||||
break;
|
||||
case ShopScreen.Category:
|
||||
s.Screen = ShopScreen.WeaponCats; s.LastActive = ""; Rest(p, s); break;
|
||||
case ShopScreen.SkillStats:
|
||||
s.Screen = ShopScreen.SkillGroups; s.LastActive = ""; Rest(p, s); break;
|
||||
default: // Root -> close
|
||||
CloseShop(p.Slot, switchAway: true); break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetFrozen(CCSPlayerPawn pawn, bool frozen, MoveType_t restore = MoveType_t.MOVETYPE_WALK)
|
||||
{
|
||||
PawnWriter.SetMoveType(pawn, frozen ? MoveType_t.MOVETYPE_NONE : restore); // schema write + notify owned by PawnWriter
|
||||
if (frozen) { var v = pawn.AbsVelocity; v.X = v.Y = v.Z = 0f; }
|
||||
}
|
||||
|
||||
// ---- screen contents ----
|
||||
private List<ShopOption> CurrentOptions(CCSPlayerController p, ShopSession s, PlayerData pd)
|
||||
{
|
||||
var opts = new List<ShopOption>();
|
||||
switch (s.Screen)
|
||||
{
|
||||
case ShopScreen.Root:
|
||||
if (_driver.WeaponShopEnabled)
|
||||
opts.Add(new('1', "Weapons", true, () => s.Screen = ShopScreen.WeaponCats));
|
||||
opts.Add(new('2', "Skills", true, () => s.Screen = ShopScreen.SkillGroups));
|
||||
break;
|
||||
|
||||
case ShopScreen.WeaponCats:
|
||||
for (int c = 0; c < CategoryOrder.Length; c++)
|
||||
{
|
||||
int ci = c;
|
||||
var list = CategoryList(CategoryOrder[c]);
|
||||
opts.Add(new(MenuKey(c), $"{CategoryDisplay(CategoryOrder[c])} ({list.Count})", true,
|
||||
() => { s.CatIndex = ci; s.Screen = ShopScreen.Category; }));
|
||||
}
|
||||
break;
|
||||
|
||||
case ShopScreen.Category:
|
||||
{
|
||||
var name = CategoryOrder[s.CatIndex];
|
||||
bool primary = name != "Pistols";
|
||||
var list = CategoryList(name);
|
||||
for (int i = 0; i < list.Count && i < 8; i++)
|
||||
{
|
||||
string item = list[i];
|
||||
int req = UnlockLevel(item);
|
||||
bool unlocked = pd.Level >= req;
|
||||
string label = unlocked ? WeaponDisplayName(item) : $"{WeaponDisplayName(item)} [L{req}]";
|
||||
opts.Add(new(MenuKey(i), label, unlocked,
|
||||
() => { SetWeapon(p, primary, item); CloseShop(p.Slot, switchAway: true); }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ShopScreen.SkillGroups:
|
||||
for (int g = 0; g < SkillGroupNames.Length; g++)
|
||||
{
|
||||
int gi = g;
|
||||
opts.Add(new(ShopOptKey(g), SkillGroupNames[g], true,
|
||||
() => { s.GroupIndex = gi; s.Screen = ShopScreen.SkillStats; }));
|
||||
}
|
||||
break;
|
||||
|
||||
case ShopScreen.SkillStats:
|
||||
{
|
||||
var keys = SkillGroupStats[s.GroupIndex];
|
||||
for (int i = 0; i < keys.Length && i < 8; i++)
|
||||
{
|
||||
string key = keys[i];
|
||||
int lvl = LevelOf(pd, key), max = DefFor(key).MaxLevel;
|
||||
bool canBuy = pd.Points > 0 && lvl < max;
|
||||
string label = $"{StatDisplay(key)} [{lvl}/{max}]";
|
||||
opts.Add(new(ShopOptKey(i), label, canBuy, () =>
|
||||
{
|
||||
BuyStat(p, key);
|
||||
if (pd.Points <= 0) CloseShop(p.Slot, switchAway: true); // spent your last point -> auto-exit
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ShopScreen.CardDraft when Draft is { } sd:
|
||||
{
|
||||
var draw = sd.CurrentDraw(p); // (cardKey, label), stable across reopen within a break
|
||||
// Cards sit on the GRENADE keys (6/7/8), NOT weapon slots 1/2/3: the player never passively holds a
|
||||
// grenade and the knife/zeus neutral-rest never produces 6-0, so the engine's weapon re-deploys can't
|
||||
// auto-pick. (Weapon slots collide — key 3 = the zeus, which shares slot3 with the knife rest.)
|
||||
for (int i = 0; i < draw.Count && i < GrenadeMenuKeys.Length; i++)
|
||||
{
|
||||
var (ckey, label) = draw[i];
|
||||
opts.Add(new(GrenadeMenuKeys[i], label, true, () =>
|
||||
{
|
||||
sd.PickCard(p, ckey);
|
||||
if (!sd.DraftPending(p)) CloseShop(p.Slot, switchAway: true); // spent your last pick -> exit, free to prep
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
private void ExecuteOption(CCSPlayerController p, ShopSession s, char key)
|
||||
{
|
||||
var pd = PdOf(p);
|
||||
if (pd is null) return;
|
||||
foreach (var opt in CurrentOptions(p, s, pd))
|
||||
if (opt.Key == key)
|
||||
{
|
||||
if (opt.Enabled) opt.Act();
|
||||
return; // matched the key — act if enabled, then stop regardless
|
||||
}
|
||||
}
|
||||
|
||||
// The ITEM a menu key maps to — shown in the menu instead of the default slot number so it reads correctly even
|
||||
// when the player has rebound their slot keys (we can't read client binds, but we CAN name the item to switch to).
|
||||
// 1/2/3 = primary/pistol/zeus slots; 6-0 = the grenades (per the in-game grenade-select order).
|
||||
private static string KeyItemLabel(char key) => key switch
|
||||
{
|
||||
'1' => "Primary",
|
||||
'2' => "Pistol",
|
||||
'3' => "Zeus",
|
||||
_ => GrenadeLabel(key), // 6-0 grenade labels come from the GrenadeMenu table (one source)
|
||||
};
|
||||
|
||||
private static string GrenadeLabel(char key)
|
||||
{
|
||||
foreach (var g in GrenadeMenu) if (g.Key == key) return g.Label;
|
||||
return key.ToString();
|
||||
}
|
||||
|
||||
private static string CategoryDisplay(string internalName) => internalName switch
|
||||
{
|
||||
"Smgs" => "SMGs",
|
||||
"Heavy" => "Machine Guns",
|
||||
_ => internalName,
|
||||
};
|
||||
|
||||
private static string StatDisplay(string key)
|
||||
{
|
||||
foreach (var (k, d) in StatList) if (k == key) return d;
|
||||
return key;
|
||||
}
|
||||
|
||||
private static string ScreenTitle(ShopSession s) => s.Screen switch
|
||||
{
|
||||
ShopScreen.Root => "SHOP",
|
||||
ShopScreen.WeaponCats => "WEAPONS",
|
||||
ShopScreen.Category => CategoryDisplay(CategoryOrder[s.CatIndex]).ToUpperInvariant(),
|
||||
ShopScreen.SkillGroups => "SKILLS",
|
||||
ShopScreen.SkillStats => SkillGroupNames[s.GroupIndex].ToUpperInvariant(),
|
||||
ShopScreen.CardDraft => "DRAFT A CARD",
|
||||
_ => "SHOP",
|
||||
};
|
||||
|
||||
// ---- world-text panels (eye-relative placement via Engine.WorldText.TryEyeFrame) ----
|
||||
private CPointWorldText? CreateShopPanel(bool rightJustify, float fontSize, float unitsPerPx)
|
||||
{
|
||||
var s = Config.Shop;
|
||||
return WorldText.Create(fontSize, unitsPerPx, s.FontName,
|
||||
System.Drawing.Color.FromArgb(255, s.ColorR, s.ColorG, s.ColorB), s.DrawBackground, 0.15f,
|
||||
rightJustify ? PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_RIGHT
|
||||
: PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_LEFT);
|
||||
}
|
||||
|
||||
private void UpdateShopPanels(CCSPlayerController p, CCSPlayerPawn pawn, ShopSession s)
|
||||
{
|
||||
if (!WorldText.TryEyeFrame(pawn, out var eye, out var fwd, out var right, out var up, out var ang)) return;
|
||||
var cfg = Config.Shop;
|
||||
|
||||
// Survival draft = a distinct 3-card overlay, not the 2-panel shop.
|
||||
if (s.Screen == ShopScreen.CardDraft) { UpdateDraftPanels(p, s, eye, fwd, right, up, ang); return; }
|
||||
|
||||
// left panel (shop options) — right-justified, sits left of centre
|
||||
if (s.OptionsEntity is null || !s.OptionsEntity.IsValid) { s.OptionsEntity = CreateShopPanel(true, cfg.FontSize, cfg.WorldUnitsPerPx); s.LastText = null; }
|
||||
if (s.OptionsEntity is not null)
|
||||
{
|
||||
var lpos = eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset - cfg.SplitOffset) + up * cfg.UpOffset;
|
||||
string lt = BuildShopLeft(p, s);
|
||||
if (s.LastText != lt) { WorldText.SetText(s.OptionsEntity, lt); s.LastText = lt; }
|
||||
WorldText.Place(s.OptionsEntity, lpos, ang);
|
||||
}
|
||||
|
||||
// right panel (info) — left-justified, sits right of centre
|
||||
if (s.InfoEntity is null || !s.InfoEntity.IsValid) { s.InfoEntity = CreateShopPanel(false, cfg.InfoFontSize, cfg.InfoWorldUnitsPerPx); s.LastInfoText = null; }
|
||||
if (s.InfoEntity is not null)
|
||||
{
|
||||
var rpos = eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset + cfg.SplitOffset) + up * cfg.UpOffset;
|
||||
string rt = BuildShopRight(p);
|
||||
if (s.LastInfoText != rt) { WorldText.SetText(s.InfoEntity, rt); s.LastInfoText = rt; }
|
||||
WorldText.Place(s.InfoEntity, rpos, ang);
|
||||
}
|
||||
}
|
||||
|
||||
// left panel — the current screen's selectable options
|
||||
private string BuildShopLeft(CCSPlayerController p, ShopSession s)
|
||||
{
|
||||
var pd = PdOf(p);
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"===== {ScreenTitle(s)} =====\n");
|
||||
if (s.Screen == ShopScreen.CardDraft && Draft is { } sd)
|
||||
sb.Append($"Picks left: {sd.PendingCards(p)} (unspent carry over)\n");
|
||||
if (pd is not null)
|
||||
foreach (var opt in CurrentOptions(p, s, pd))
|
||||
sb.Append($"[{KeyItemLabel(opt.Key)}] {opt.Label}\n"); // show the ITEM to switch to, not the default key#
|
||||
// home = Root in TDM, SkillGroups in a skills-only mode, or the survival draft -> close; deeper screens -> back
|
||||
bool atHome = s.Screen is ShopScreen.Root or ShopScreen.CardDraft
|
||||
|| (!_driver.WeaponShopEnabled && s.Screen == ShopScreen.SkillGroups);
|
||||
sb.Append(atHome ? "[Health] / [Crouch] close\n" : "[Health] back\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// right panel — server/about blurb (top) then the player's own stats (bottom), in a much smaller font
|
||||
private string BuildShopRight(CCSPlayerController p)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in Config.Shop.InfoLines) sb.Append(line).Append('\n'); // server / about (!info, !about)
|
||||
|
||||
var pd = PdOf(p);
|
||||
if (pd is null) return sb.ToString();
|
||||
|
||||
double kd = pd.Deaths > 0 ? (double)pd.Kills / pd.Deaths : pd.Kills;
|
||||
int xpPct = (int)(pd.Xp * 100 / Math.Max(1, XpToNext(pd.Level)));
|
||||
sb.Append("----- YOU -----\n"); // player (!me, !stats)
|
||||
sb.Append($"{RankName(pd)}\n");
|
||||
sb.Append($"Lv {pd.Level} P{Roman(pd.Prestige)} XP {xpPct}% {pd.Points}pt\n");
|
||||
sb.Append($"K {pd.Kills} D {pd.Deaths} KD {kd:F2} Streak {pd.Streak}\n");
|
||||
var (effHs, effOut, effIn, effXp) = EffectiveMultipliers(p, pd);
|
||||
sb.Append($"Out x{effOut:F2} In x{effIn:F2} HS x{effHs:F2} XP x{effXp:F2}\n");
|
||||
if (_driver.WeaponShopEnabled)
|
||||
{
|
||||
var (pri, sec) = ResolveLoadout(p);
|
||||
sb.Append($"{WeaponDisplayName(pri)} / {WeaponDisplayName(sec)}\n");
|
||||
}
|
||||
else if (_driver.LadderStatusLine(pd) is { } rungLine) // weapon dictated by the ladder
|
||||
sb.Append(rungLine).Append('\n');
|
||||
var owned = StatList.Where(st => LevelOf(pd, st.Key) > 0).ToList();
|
||||
if (owned.Count > 0)
|
||||
{
|
||||
string Cell(int j) => $"{StatDisplay(owned[j].Key)} {LevelOf(pd, owned[j].Key)}/{DefFor(owned[j].Key).MaxLevel}";
|
||||
sb.Append("- Stats -\n");
|
||||
for (int i = 0; i < owned.Count; i += 2) // two columns -> wider, fewer rows
|
||||
sb.Append(i + 1 < owned.Count ? $"{Cell(i)} {Cell(i + 1)}\n" : $"{Cell(i)}\n");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// each player sees only their own shop panels
|
||||
private void OnCheckTransmit_Shop(CCheckTransmitInfoList infoList) =>
|
||||
HideForeignSlotEntities(infoList, _shop, static (info, s) =>
|
||||
{
|
||||
if (s.OptionsEntity is { IsValid: true }) info.TransmitEntities.Remove(s.OptionsEntity);
|
||||
if (s.InfoEntity is { IsValid: true }) info.TransmitEntities.Remove(s.InfoEntity);
|
||||
foreach (var c in s.Cards) if (c is { IsValid: true }) info.TransmitEntities.Remove(c);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue