Search the source of every open source package.
452 results
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
@*
The floating name tag above an avatar. StreamPlayer.Setup() points its Viewer at the right viewer,
and we render their display name in their chat colour.
*@
<root>
<div class="username" style="@NameStyle">
@Viewer.DisplayName
</div>
</root>
@code
{
/// <summary>
/// The viewer this tag is labelling. Set by StreamPlayer when the avatar is spawned.
/// </summary>
public Streamer.Viewer Viewer { get; set; }
/// <summary>
/// Colour the name with the viewer's chat colour, if they have one set.
/// </summary>
string NameStyle => Viewer.Color.HasValue ? $"color: {Viewer.Color.Value.Hex}" : null;
/// <summary>
/// PanelComponent rebuilds the markup whenever this hash changes - so include anything we display.
/// </summary>
protected override int BuildHash() => System.HashCode.Combine( Viewer.DisplayName, Viewer.Color );
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("DifficultyPanel.razor.scss")]
@{
var isClient = Networking.IsClient;
int diff = DontChangeGameDifficulty ? DifficultyToDisplay : Manager.Instance.Difficulty;
// var canDecreaseDifficulty = diff > Manager.MinDifficulty;
var isAtMinDifficulty = diff <= Manager.MinDifficulty - (DontChangeGameDifficulty ? 1 : 0);
var canIncreaseDifficulty = DontChangeGameDifficulty || ( diff < Manager.MaxDifficulty && Manager.Instance.HasBeatenDifficulty(diff) );
var isAtMaxDifficulty = diff >= Manager.MaxDifficulty;
var difficultyDesc = Manager.GetDescriptionForDifficulty( diff );
}
<root>
<div class="top-row">
@if( isAtMinDifficulty)
{
<div class="difficulty_decrease disabled_button" style="opacity:0;"> </div>
}
else
{
<div class="difficulty_decrease @(isClient && !DontChangeGameDifficulty ? "disabled_button" : "")" onclick="@(() => ButtonLeft())">
@if(Input.UsingController && (!isClient || DontChangeGameDifficulty)) { <InputHint class="inputbutton" Button="Slot1" /> }
</div>
}
<div class="middle">
<div class="difficulty_label" style="color:@(Manager.GetDifficultyLabelColor(diff).Rgba);">@($"{Manager.GetNameForDifficulty(diff)}")</div>
@if ( !string.IsNullOrEmpty( difficultyDesc ) )
{
<label class="description">@difficultyDesc</label>
}
</div>
@if(isAtMaxDifficulty)
{
<div class="difficulty_increase disabled_button" style="opacity:0;"></div>
}
else
{
if(canIncreaseDifficulty)
{
<div class="difficulty_increase @(isClient && !DontChangeGameDifficulty ? "disabled_button" : "")" onclick="@(() => ButtonRight())">
@if(Input.UsingController && (!isClient || DontChangeGameDifficulty)) { <InputHint class="inputbutton" Button="Slot3" /> }
</div>
}
else
{
<div class="difficulty_locked" Tooltip=@($"Beat {Manager.GetNameForDifficulty(diff)} first")></div>
}
}
</div>
</root>
@code
{
public bool DontChangeGameDifficulty { get; set; }
public static int DifficultyToDisplay { get; set; } = -1; // -1 equals all difficulties combined
public override void Tick()
{
base.Tick();
if(!Input.UsingController)
return;
if(Networking.IsClient && !DontChangeGameDifficulty)
return;
int diff = DontChangeGameDifficulty ? DifficultyToDisplay : Manager.Instance.Difficulty;
var isAtMinDifficulty = diff <= Manager.MinDifficulty - (DontChangeGameDifficulty ? 1 : 0);
var canIncreaseDifficulty = DontChangeGameDifficulty || ( diff < Manager.MaxDifficulty && Manager.Instance.HasBeatenDifficulty(diff) );
var isAtMaxDifficulty = diff >= Manager.MaxDifficulty;
if(Input.Pressed("Slot1") && !isAtMinDifficulty) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); ButtonLeft(); }
else if(Input.Pressed("Slot3") && !isAtMaxDifficulty) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); ButtonRight(); }
}
protected override int BuildHash()
{
return HashCode.Combine(
Manager.Instance.Difficulty,
DifficultyToDisplay,
Input.UsingController
);
}
void ButtonLeft()
{
GameSettingsSystem.Save();
ButtonLeftAsync();
}
async void ButtonLeftAsync()
{
if(DontChangeGameDifficulty)
{
if(DifficultyToDisplay == Manager.MinDifficulty)
DifficultyToDisplay = -1;
else
DifficultyToDisplay = Math.Max(DifficultyToDisplay - 1, Manager.MinDifficulty);
}
else
{
Manager.Instance.FadeRpc(fadeIn: false);
await Task.Frame();
var difficulty = Math.Max(Manager.Instance.Difficulty - 1, Manager.MinDifficulty);
Manager.Instance.SetDifficulty(difficulty);
}
}
void ButtonRight()
{
ButtonRightAsync();
GameSettingsSystem.Save();
}
async void ButtonRightAsync()
{
if(DontChangeGameDifficulty)
{
DifficultyToDisplay = Math.Min(DifficultyToDisplay + 1, Manager.MaxDifficulty);
}
else
{
Manager.Instance.FadeRpc(fadeIn: false);
await Task.Frame();
var difficulty = Math.Min(Manager.Instance.Difficulty + 1, Manager.MaxDifficulty);
Manager.Instance.SetDifficulty(difficulty);
}
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("InfoPanel.razor.scss")]
<root>
@{
// todo: split each ui line into its own component
var xp_percent = Player.ExperienceCurrent / (float)Player.ExperienceRequired;
var xp_transition_enabled = Player.GetUiStat( PlayerStat.DisableXpBarTransition ) == 0f;
var hp_percent = Math.Clamp(Math.Max(Player.Health, 0f) / Player.GetSyncStat(PlayerStat.MaxHp), 0f, 1f);
// var hp_regen = Player.Stats[PlayerStat.HealthRegen] + (Player.IsMoving ? 0f : Player.Stats[PlayerStat.HealthRegenStill]);
var hp_regen = Player.GetHpRegenAmount(forDisplay: true);
var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;
}
<div class="info_bar">
<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_xp_mask.png\");")>
<div class="info_bar_overlay @(xp_transition_enabled ? "xp_transition_on" : "")" style="width:@(xp_percent * 100f)%; background-color: #8888ff77;"></div>
@if(Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses && Player.AvailableCurseCount > 0)
{
var cursePercent = Player.IsBeingShownCurseChoices ? 1f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 0f, 1f);
var colorSpeed = Player.IsBeingShownCurseChoices ? 15f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 3f, 15f, EasingType.SineIn);
var curseColor = Color.Lerp(new Color(0.2f, 0.1f, 0.4f, 0.5f), new Color(0.8f, 0.1f, 0.9f, 0.25f), Utils.Map(Utils.FastSin(RealTime.Now * colorSpeed), -1f, 1f, 0f, 1f));
<div class="info_bar_overlay" style="width:@(cursePercent * 100f)%; background-color: @curseColor.Rgba;"></div>
}
</div>
<div class="info_bar_label">XP</div>
<div class="data_container">
<div class="data" style="opacity: 0.3;">@($"LVL {Player.Level}")</div>
</div>
</div>
@{
var regularMaxDashes = (int)MathF.Round( Player.GetUiStat( PlayerStat.NumDashes ) );
var maxDashes = regularMaxDashes + Player.NumTempDashesAvailable;
var totalAvailable = Player.NumDashesAvailable + Player.NumTempDashesAvailable;
}
@if( maxDashes < 9 )
{
<div class="info_dash_container" style="gap: @(Utils.Map(maxDashes, 1, 8, 8, 4, EasingType.QuadIn))px;">
<div class="dash_bar_container")>
@for(int i = 0; i < maxDashes; i++)
{
// Bars i < regularMaxDashes are regular dashes; i >= regularMaxDashes are temporary (from PerkDashTemporary).
// Temp bars are always shown to the right and colored purple.
var isTemp = i >= regularMaxDashes;
float progress;
bool isRecharging;
if ( isTemp )
{
// Temp dashes are either fully available or gone — no partial recharge animation.
int tempIndex = i - regularMaxDashes;
progress = tempIndex < Player.NumTempDashesAvailable ? 1f : 0f;
isRecharging = false;
}
else
{
// The bar at NumDashesAvailable is the one currently recharging (partial fill).
// Bars below it are full; bars above it are empty.
progress = i == Player.NumDashesAvailable ? Player.DashRechargeProgress : (i < Player.NumDashesAvailable ? 1f : 0f);
isRecharging = i >= Player.NumDashesAvailable;
}
var filledColor = isTemp ? "#b34dff88" : "#00ff0088";
var rechargingColor = isTemp ? "#b34dff44" : "#00ff4444";
<div class="info_bar" style="background-color: #00000077;">
<div class="info_bar_overlay" style="width:@(progress * 100f)%; background-color: @(isRecharging ? rechargingColor : filledColor);"></div>
</div>
}
</div>
@if(maxDashes <= 0)
{
<div class="info_bar">
</div>
}
<div class="info_bar_label" style="left: 8px;">
@("DASH")
</div>
</div>
}
else
{
var dashPercent = (float)totalAvailable / (float)maxDashes;
<div class="info_bar">
<div class="info_bar_overlay" style="width:@(dashPercent * 100f)%; background-color: #00ff0055; transition: all 0.2s ease-in-out;"></div>
<div class="info_bar_label">DASH</div>
<div class="data_container">
<div class="data">@maxDashes</div>
<div class="data">/</div>
<div class="data">@totalAvailable</div>
</div>
</div>
}
<div class="info_bar">
<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_hp_mask.png\");")>
@if ( hpBarHidden )
{
<div class="info_bar_overlay" style="width: 100%; background-color: #7a0040ff;"></div>
}
else
{
<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ffffffff; transition: all 0.4s ease-in-out "></div>
<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ff0000ff; transition: all 0.2s ease-in-out;"></div>
}
</div>
<div class="info_bar_label">HP</div>
<div class="data_container">
<div class="data">@($"{(int)MathF.Round(Player.GetSyncStat(PlayerStat.MaxHp))}")</div>
<div class="data">/</div>
@if ( hpBarHidden )
{
<div class="data">?</div>
}
else
{
<div class="data">@($"{(Math.Max(Player.Health, 0f) > 0f && Math.Max(Player.Health, 0f) < 1f ? (int)Math.Ceiling(Math.Max(Player.Health, 0f)) : (int)Math.Round(Math.Max(Player.Health, 0f)))}")</div>
@if (Math.Abs(hp_regen) > 0f)
{
<div class="data" style="color:@((hp_regen > 0f ? new Color(0f, 1f, 0f) : new Color(1f, 0f, 0f)).Rgba); letter-spacing: 2px; padding-right: 10px;">@($"{(hp_regen > 0f ? "+" : "")}{hp_regen.ToString("0.##")}")</div>
}
}
</div>
</div>
@if (Player.Armor > 0)
{
<div class="armor_icon" style="transform: scale(@(Utils.Map(Player.RealTimeSinceArmorChanged, 0f, 0.5f, 1.35f, 1f, EasingType.QuadOut)));">
<label class="armor_text">@($"{MathX.CeilToInt(Player.Armor)}")</label>
</div>
}
</root>
@code
{
public Player Player { get; set; }
protected override int BuildHash()
{
if( Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses )
{
return HashCode.Combine(RealTime.Now);
}
var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;
var hpHash = HashCode.Combine(
Player.Health,
Player.GetSyncStat(PlayerStat.MaxHp),
Player.GetSyncStat( PlayerStat.HealthRegen ),
Player.GetSyncStat( PlayerStat.HealthRegenStill ),
Player.IsMoving,
Player.GetHpRegenAmount( forDisplay: true ),
hpBarHidden
);
var armorHash = HashCode.Combine(
Player.RealTimeSinceArmorChanged > 0.5f ? 0f : Player.RealTimeSinceArmorChanged.Relative,
Player.Armor
);
var xpHash = HashCode.Combine(
Player.Level,
Player.ExperienceCurrent,
Player.ExperienceRequired
);
return HashCode.Combine(
Player.DashRechargeProgress,
Player.NumDashesAvailable,
Player.NumTempDashesAvailable,
Player.GetUiStat( PlayerStat.NumDashes ),
hpHash,
armorHash,
xpHash
);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits Panel
@attribute [StyleSheet("LoadoutPanel.razor.scss")]
<root>
<div class="hide_button" onclick=@(() => Close())></div>
<div class="title_label">Loadout</div>
<div class="tabs">
@for(int t = 0; t < TabNames.Length; t++)
{
var tabIndex = t;
<div class="tab @(_selectedTab == tabIndex ? "active" : "")" onclick=@(() => SelectTab(tabIndex))>
@TabNames[tabIndex]
</div>
}
</div>
<div class="panel_body">
@if(_selectedTab == 2)
{
var equippedGems = ProgressManager.GetEquippedGems();
var ownedGems = ProgressManager.GetOwnedItemsByCategory(ShopItemCategory.Gem);
var maxGemSlots = ProgressManager.GetSelectedGunSocketCount();
var gemsFull = equippedGems.Count >= maxGemSlots;
@if(ownedGems.Count > 0)
{
<div class="items_grid">
@foreach(var gem in ownedGems)
{
var isEquipped = equippedGems.Contains(gem.Id);
var gemCapture = gem;
<LoadoutItemPanel Item=@gem IsSelected=@isEquipped IsSlotFull=@(!isEquipped && gemsFull) OnClick=@(() => OnSelectGem(gemCapture)) />
}
</div>
}
else
{
<div class="coming_soon">Buy gems in the Shop to equip them here.</div>
}
}
else
{
var items = GetItemsForTab(_selectedTab);
@if(_selectedTab == 1)
{
var selectedCharmIds = ProgressManager.GetSelectedCharmIds();
var maxCharmSlots = ProgressManager.GetSelectedGunCharmSlotCount();
var charmsFull = selectedCharmIds.Count >= maxCharmSlots;
<div class="items_grid">
@foreach(var item in items)
{
var isSelected = selectedCharmIds.Contains(item.Id);
var itemCapture = item;
<LoadoutItemPanel Item=@item IsSelected=@isSelected IsSlotFull=@(!isSelected && charmsFull && maxCharmSlots > 1) OnClick=@(() => OnSelectItem(itemCapture)) />
}
</div>
}
else
{
var selectedGunId = ProgressManager.GetSelectedGunId();
<div class="items_grid">
@foreach(var item in items)
{
var isSelected = item.Id == selectedGunId;
var itemCapture = item;
<LoadoutItemPanel Item=@item IsSelected=@isSelected OnClick=@(() => OnSelectItem(itemCapture)) />
}
</div>
}
}
</div>
</root>
@code
{
static readonly string[] TabNames = { "Guns", "Charms", "Gems" };
static readonly ShopItemCategory[] TabCategories = { ShopItemCategory.Gun, ShopItemCategory.Charm, ShopItemCategory.Gem };
static int _selectedTab = 0;
static bool _initialTabSet = false;
protected override void OnAfterTreeRender(bool firstTime)
{
base.OnAfterTreeRender(firstTime);
if(firstTime)
{
GunPreviewController.Show();
if(!_initialTabSet)
{
_selectedTab = BestStartingTab();
_initialTabSet = true;
}
}
}
int BestStartingTab()
{
for(int i = 0; i < TabCategories.Length; i++)
if(GetItemsForTab(i).Count > 0)
return i;
return 0;
}
List<ShopItemDef> GetItemsForTab(int tab)
{
if(tab < 0 || tab >= TabCategories.Length)
return new List<ShopItemDef>();
return ProgressManager.GetOwnedItemsByCategory(TabCategories[tab]);
}
void OnSelectItem(ShopItemDef item)
{
if(_selectedTab == 0)
{
var currentGunId = ProgressManager.GetSelectedGunId();
var deselecting = currentGunId == item.Id;
var targetDef = deselecting ? ProgressManager.DefaultGun : item;
// Unequip gems that exceed the target gun's socket count
var newSocketCount = targetDef.GemSocketCount > 0 ? targetDef.GemSocketCount : 3;
var equippedGems = ProgressManager.GetEquippedGems();
while(equippedGems.Count > newSocketCount)
{
ProgressManager.UnequipGem(equippedGems[equippedGems.Count - 1]);
equippedGems = ProgressManager.GetEquippedGems();
}
// Trim selected charms to the target gun's charm slot count
var newCharmSlots = targetDef.CharmSlotCount > 0 ? targetDef.CharmSlotCount : 1;
var selectedCharms = ProgressManager.GetSelectedCharmIds();
if(selectedCharms.Count > newCharmSlots)
ProgressManager.SetSelectedCharmIds(selectedCharms.Take(newCharmSlots).ToList());
ProgressManager.SetSelectedGunId(deselecting ? ProgressManager.DefaultGun.Id : item.Id);
// Broadcast gun swap so all clients see the new model
var player = Manager.Instance.LocalPlayer;
if(player.IsValid())
{
var gunPrefab = ProgressManager.GetPrefabPath(targetDef.Id) ?? "prefabs/guns/gun_default.prefab";
var charmIds = ProgressManager.GetSelectedCharmIds();
var charm0 = charmIds.Count > 0 ? ProgressManager.GetPrefabPath(charmIds[0]) ?? "" : "";
var charm1 = charmIds.Count > 1 ? ProgressManager.GetPrefabPath(charmIds[1]) ?? "" : "";
var (g0, g1, g2, g3) = GetEquippedGemPrefabs();
player.SwapGunRpc(gunPrefab, charm0, g0, g1, g2, g3, charm1);
}
GunPreviewController.Refresh();
}
else if(_selectedTab == 1)
{
var selectedIds = ProgressManager.GetSelectedCharmIds();
var maxSlots = ProgressManager.GetSelectedGunCharmSlotCount();
if(selectedIds.Contains(item.Id))
{
// Deselect
ProgressManager.SetSelectedCharmIds(selectedIds.Where(id => id != item.Id).ToList());
}
else if(maxSlots == 1)
{
// Single-slot gun: replace
ProgressManager.SetSelectedCharmIds(new List<string> { item.Id });
}
else if(selectedIds.Count < maxSlots)
{
// Multi-slot gun with a free slot: add
ProgressManager.SetSelectedCharmIds(selectedIds.Concat(new[] { item.Id }).ToList());
}
else
{
// All slots full: do nothing, player must deselect first
return;
}
// Broadcast charm swap so all clients see the new model
var player = Manager.Instance.LocalPlayer;
if(player.IsValid())
{
var ids = ProgressManager.GetSelectedCharmIds();
var charm0 = ids.Count > 0 ? ProgressManager.GetPrefabPath(ids[0]) ?? "" : "";
var charm1 = ids.Count > 1 ? ProgressManager.GetPrefabPath(ids[1]) ?? "" : "";
player.SwapCharmRpc(charm0, charm1);
}
GunPreviewController.Refresh();
}
StateHasChanged();
}
void OnSelectGem(ShopItemDef gem)
{
var equipped = ProgressManager.GetEquippedGems();
var maxSlots = ProgressManager.GetSelectedGunSocketCount();
if(equipped.Contains(gem.Id))
ProgressManager.UnequipGem(gem.Id);
else if(equipped.Count < maxSlots)
ProgressManager.EquipGem(gem.Id, maxSlots);
BroadcastGemSwap();
GunPreviewController.Refresh();
StateHasChanged();
}
void BroadcastGemSwap()
{
var player = Manager.Instance.LocalPlayer;
if(!player.IsValid()) return;
var (g0, g1, g2, g3) = GetEquippedGemPrefabs();
player.SwapGemsRpc(g0, g1, g2, g3);
}
static (string, string, string, string) GetEquippedGemPrefabs()
{
var equipped = ProgressManager.GetEquippedGems();
string Get(int i) => i < equipped.Count ? (ProgressManager.GetPrefabPath(equipped[i]) ?? "") : "";
return (Get(0), Get(1), Get(2), Get(3));
}
void SelectTab(int tabIndex)
{
if(_selectedTab == tabIndex) return;
_selectedTab = tabIndex;
StateHasChanged();
}
void Close()
{
GunPreviewController.Hide();
Manager.Instance.ShowLoadoutPanel = false;
}
protected override int BuildHash()
{
return System.HashCode.Combine(
Manager.Instance.ShowLoadoutPanel,
_selectedTab,
ProgressManager.StateVersion
);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Text.Json;
@inherits Panel
@attribute [StyleSheet("LobbyNametagPanel.razor.scss")]
<root>
<LeaderboardPlayerIcon style="width: 42px; height: 42px;" PlayerInfo=@PlayerInfo />
<div class="name">@PlayerInfo.displayName</div>
@if(Networking.IsHost || PlayerInfo.steamId == Game.SteamId)
{
<i class="remove-btn" onclick=@KickPlayer>disabled_by_default</i>
}
</root>
@code
{
const int MAX_NAME_LENGTH = 13;
public PlayerInfo PlayerInfo { get; set; }
void KickPlayer()
{
Manager.Instance?.RequestKickPlayer(PlayerInfo.steamId);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerPerks.razor.scss")]
@{
var player = Manager.Instance.SelectedPlayer.IsValid()
? Manager.Instance.SelectedPlayer
: Manager.Instance.LocalPlayer;
if (!player.IsValid())
return;
var height = Manager.Instance.Players.Count > 1 ? 880f : 950f;
}
<root style="height: @(height)px; max-height: @(height)px;">
@if( player.IsProxy )
{
if (player.SyncPerks.Count == 0)
return;
int index = 0;
<div class="itemlist" style="opacity:@(1f);">
@foreach (var pair in player.SyncPerks)
{
var rot = Utils.Map((player.PerkRandomRotationSeed + index * 3f) % 10, 0, 9, -5f, 5f);
<PerkIconStatic style="width: 50px; height: 50px; transform: rotate(@(rot)deg);" PerkType=@(PerkManager.IdentityToType(pair.Key)) Level=@(pair.Value) ProgressLevel=@(pair.Value) Banished=@false />
index++;
}
</div>
}
else
{
if (player.Perks.Count == 0)
return;
int index = 0;
<div class="itemlist @(Manager.Instance.IsGameOver ? "" : "itemlist_hover")">
@foreach (var (_, perk) in player.Perks)
{
var rot = Utils.Map((player.PerkRandomRotationSeed + index * 3f) % 10, 0, 9, -5f, 5f);
<PerkIcon style="width: 50px; height: 50px;" Perk=@perk Angle=@rot Banished=@false />
index++;
}
</div>
}
</root>
@code
{
protected override int BuildHash()
{
var player = Manager.Instance.SelectedPlayer.IsValid()
? Manager.Instance.SelectedPlayer
: Manager.Instance.LocalPlayer;
var perkHash = player.IsValid()
? player.PerkHash
: 0;
var perkCount = player.IsValid()
? player.SyncPerks.Count
: 0;
return HashCode.Combine(
player,
// player.Id // todo: this instead?
perkCount,
perkHash,
Manager.Instance.IsGameOver
// Time.Now
);
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
@if (Header != null)
{
<div class="header">@Header</div>
}
<div class="body menuinner">@Body</div>
</root>
@code
{
[Parameter] public RenderFragment Header { get; set; }
[Parameter] public RenderFragment Body { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
@Path
</root>
@code
{
public string Path { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox
<root>
<Button class="menu-action primary wide" Icon="➕" Text="#spawnmenu.spawnlist.new_button" Disabled=@( !CanCreate() ) @onclick=@CreatePopup></Button>
</root>
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel
<root>
<div class="menu-segmented-group">
@{
var activeMode = SpawnMenuHost.GetActiveMode();
foreach ( var mode in Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>().OrderBy( x => x.Type.Order ) )
{
if ( !mode.Attribute.CheckCondition() ) continue;
var activeClass = mode.Type.TargetType == activeMode?.GetType() ? "active" : "";
<div class="menu-mode-button @activeClass" @onclick="@( () => SpawnMenuHost.SwitchMode( mode.Type.Name ) )">
<div class="icon">@mode.Type.Icon</div>
<div class="title">@mode.Type.Title</div>
</div>
}
}
</div>
</root>
@code
{
protected override int BuildHash() => HashCode.Combine( SpawnMenuHost.GetActiveMode(), Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>() );
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Breakout
@inherits Panel
@if ( Game.IsValid() && Game.State != GameState.Menu && Game.Level <= 1 )
{
<root>
<div class="container">
<span class="controls-label">CONTROLS</span>
<div class="column">
<div class="input">
@*There is no mouse/ controller move input hint so we do this.*@
@if(Input.UsingController)
{
<label class="type">Move</label><img src="ui/glyphs/xbox/leftjoystickbutton.svg" class="mouse-glyph" />
}
else
{
<label class="type">Move Paddle</label><img src="ui/glyphs/default/mouse.svg" class="mouse-glyph" />
}
</div>
<div class="input">
<label class="type">Launch</label><InputHint Action="attack1" class="key-glyph" Dark=@true />
</div>
</div>
</div>
</root>
}
@code
{
/// <summary>
/// The game this bar reads score, lives, level and mode from.
/// </summary>
[Parameter] public BreakoutGame Game { get; set; }
// Update the ui when the game state changes or when the input method changes.
protected override int BuildHash() => System.HashCode.Combine(Game?.State, Game?.Mode, Game?.Level, Input.UsingController);
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Breakout
@inherits Panel
@*
HelpScreen is the "how to play" page you reach from the menu's HELP button. It shows a guide
of every block type and what it does. Pressing BACK runs the Back callback, which tells the
menu to show itself again.
*@
<root>
<div class="help">
<button class="back-btn" onclick=@(() => Back?.Invoke())>
<span class="back-icon">◀</span>
BACK
</button>
<div class="help-body">
<div class="help-title">HELP</div>
<div class="help-sub">Here is what each brick does when you hit it.</div>
<div class="block-grid">
@foreach ( var block in Blocks )
{
<div class="block-card">
<img class="block-icon" src="@block.Icon" />
<div class="block-info">
<div class="block-name">@block.Name</div>
<div class="block-desc">@block.Description</div>
</div>
</div>
}
</div>
</div>
</div>
</root>
@code
{
/// <summary>
/// Called when the player presses BACK, so the menu can show itself again.
/// </summary>
[Parameter] public Action Back { get; set; }
/// <summary>
/// One row in the block guide: the icon to show, the block's name, and a short description.
/// </summary>
private record BlockGuideEntry( string Icon, string Name, string Description );
// The list shown on the help page. Add a new line here to document another block type.
private static readonly BlockGuideEntry[] Blocks =
{
new( "textures/blocks/normal.png", "Brick", "Breaks in a single hit." ),
new( "textures/blocks/armoured.png", "Armoured", "Takes two hits to break." ),
new( "textures/blocks/unbreakable.png", "Unbreakable", "Can't be broken, so play around it." ),
new( "textures/blocks/explosion.png", "Explosive", "Blows up nearby bricks when it breaks." ),
new( "textures/blocks/speed.png", "Fast Ball", "Turns your ball into a faster one." ),
new( "textures/blocks/big_ball.png", "Big Ball", "Makes your ball big enough to smash armour in one hit." ),
new( "textures/blocks/fire_ball.png", "Fireball", "Turns your ball into a fireball that burns straight through bricks." ),
new( "textures/blocks/split.png", "Multi-Ball", "Drops a pickup that splits your ball into more." ),
new( "textures/blocks/expand.png", "Expand", "Drops a pickup that makes your paddle wider." ),
};
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.UI.Navigation;
@using Machines.Components;
@using Machines.Resources;
@namespace Machines.UI
@inherits Panel
@{
var flow = LobbyFlow.Current;
var map = flow?.SelectedMap;
var total = Connection.All.Count;
var ready = ReadyCount();
}
<root class="lobby-status @(Visible ? "" : "hidden") @(Input.UsingController ? "controller" : "mouse")" onclick=@GoToPlay>
@if ( Visible )
{
<div class="ls-map">
<div class="ls-thumb-wrap">
<image @ref="ThumbImage" class="ls-thumb" />
@if ( map?.Thumbnail == null )
{
<div class="ls-thumb-empty">NO IMAGE</div>
}
</div>
<div class="ls-map-info">
<span class="ls-eyebrow">IN LOBBY</span>
<span class="ls-title">@(map?.Title ?? "No map")</span>
</div>
</div>
<PlayerSlots class="ls-slots" />
<div class="ls-status">
<span class="ls-state">@StateLabel( flow )</span>
<span class="ls-ready">@ready / @System.Math.Max( total, 1 ) READY</span>
</div>
}
</root>
@code
{
public Sandbox.UI.Image ThumbImage { get; set; }
// Show when a lobby exists, except on /play which shows the full picker.
private bool Visible =>
LobbyFlow.Current is not null
&& MainMenuPanel.Instance?.CurrentUrl != "/play";
public override void Tick()
{
base.Tick();
if ( ThumbImage != null )
ThumbImage.Texture = LobbyFlow.Current?.SelectedMap?.Thumbnail;
StackAbovePatchNotes();
}
// Sit above the patch-notes card; explicit px so the CSS transition works both ways.
private void StackAbovePatchNotes()
{
var revision = FindRootPanel()?.Descendants.OfType<RevisionCard>().FirstOrDefault();
var cardHeight = revision is not null && revision.IsVisible
? revision.Box.Rect.Height * ScaleFromScreen + 14f
: 0f;
// Nudge up on the main menu home page so it clears the home layout.
var menuOffset = MainMenuPanel.Instance?.CurrentUrl == "/home" ? 32f : 0f;
Style.Bottom = Length.Pixels( 56f + cardHeight + menuOffset ); // $deadzone-y (+ card + gap)
}
// Click navigates back to the map selector.
private void GoToPlay()
{
this.Navigate( "/play" );
}
private static string StateLabel( LobbyFlow flow )
{
if ( flow == null )
return "WAITING";
return flow.State switch
{
LobbyState.Countdown => $"STARTING IN {System.Math.Max( 0, (int)System.Math.Ceiling( (float)flow.CountdownEnds ) )}…",
LobbyState.Launching => "STARTING…",
_ => "WAITING"
};
}
private static int ReadyCount()
{
var flow = LobbyFlow.Current;
if ( flow == null )
return 0;
return Connection.All.Count( c => flow.Ready.TryGetValue( c.Id, out var r ) && r );
}
protected override int BuildHash()
{
var flow = LobbyFlow.Current;
var countdownTick = flow?.State == LobbyState.Countdown ? (int)((float)flow.CountdownEnds * 4) : 0;
return System.HashCode.Combine(
Visible,
flow?.SelectedMapIdent,
flow?.State,
Connection.All.Count,
ReadyCount(),
countdownTick,
Input.UsingController );
}
}
@using Sandbox;
@using Sandbox.UI;
@using Machines.Components;
@using Machines.Race;
@using System;
@using System.Collections.Generic;
@namespace Machines.UI
@inherits Panel
@{
EnsureBaked();
var hasMap = _texture != null && _maxExtent > 0.001f && _trackRadius > 0.001f;
var center = new Vector2( 0.5f, 0.5f ); // whole track, centred on the map
var fitRadius = PanelSize * 0.5f - RimMargin; // fit the whole track inside the round clip
var disp = _maxExtent * (fitRadius / _trackRadius); // displayed track-image size in logical px
}
<root class="minimap @(hasMap ? "" : "empty")">
<div class="clip">
@if ( hasMap )
{
var imgLeft = PanelSize * 0.5f - center.x * disp;
var imgTop = PanelSize * 0.5f - center.y * disp;
<image @ref="MapImage" class="map-img"
style="width: @(disp)px; height: @(disp)px; left: @(imgLeft)px; top: @(imgTop)px;" />
@foreach ( var m in GetCheckpointMarkers( center, disp ) )
{
<div class="cp-bar @(m.IsFinish ? "finish" : "")"
style="left: @(m.X)px; top: @(m.Y)px; width: @(m.LengthPx)px; transform: translate(-50%, -50%) rotate(@(m.AngleDeg)deg);"></div>
}
@foreach ( var dot in GetBlipDots( center, disp ) )
{
var c = dot.Color;
var rgb = $"rgb({(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)})";
<div class="dot @dot.Class"
style="left: @(dot.X)px; top: @(dot.Y)px; background-color: @rgb;"></div>
}
}
</div>
</root>
@code
{
/// <summary>
/// Diameter of the minimap in logical px.
/// </summary>
public float PanelSize { get; set; } = 320;
/// <summary>
/// Inset (logical px) from the minimap edge that the track is fit within.
/// </summary>
private const float RimMargin = 32f;
/// <summary>
/// Resolution of the baked track texture (square).
/// </summary>
public int TextureResolution { get; set; } = 1024;
/// <summary>
/// Half-width of the drawn road in world units; tune to match the track mesh.
/// </summary>
public float RoadHalfWidth { get; set; } = 80f;
/// <summary>
/// Outline thickness around the road, in world units.
/// </summary>
public float OutlineWorld { get; set; } = 4f;
private Image MapImage { get; set; }
private Texture _texture;
private RacingLine _bakedFor; // rebake when this changes
private Vector2 _center; // world-XY centre of the baked region
private float _maxExtent; // world units covered by the baked region
private float _trackRadius; // world units from centre to outermost road edge
public override void Tick()
{
base.Tick();
if ( MapImage != null && _texture != null )
MapImage.Texture = _texture;
}
/// <summary>
/// Bakes the track texture once the racing line is available, or rebakes on change.
/// </summary>
private void EnsureBaked()
{
var line = RacingPath.Current?.Optimal;
if ( line is null || !line.IsValid )
return;
if ( _texture != null && _bakedFor == line )
return;
Bake( line, RoadHalfWidth );
_bakedFor = line;
}
private void Bake( RacingLine line, float halfWidth )
{
var res = TextureResolution;
// AA feather sized to ~1 displayed pixel for a smooth outline.
var (_, _, trackRadius) = TrackMapBaker.ComputeBounds( line, halfWidth, OutlineWorld );
var pxPerWorld = (PanelSize * 0.5f - RimMargin) / trackRadius;
var aaW = pxPerWorld > 0.0001f ? 1f / pxPerWorld : 0f;
var bake = TrackMapBaker.Bake( line, halfWidth, OutlineWorld, res, aaW );
if ( bake is null )
return;
_center = bake.Center;
_maxExtent = bake.MaxExtent;
_trackRadius = bake.TrackRadius;
_texture = Texture.Create( res, res )
.WithFormat( ImageFormat.RGBA8888 )
.WithData( bake.Rgba )
.Finish();
}
private struct CpMarker
{
public float X;
public float Y;
public float AngleDeg;
public float LengthPx;
public bool IsFinish;
}
/// <summary>
/// Bar across the track at each checkpoint, oriented to the line tangent.
/// </summary>
private List<CpMarker> GetCheckpointMarkers( Vector2 center, float disp )
{
var list = new List<CpMarker>();
if ( _maxExtent < 0.001f )
return list;
var line = RacingPath.Current?.Optimal;
var half = PanelSize * 0.5f;
var pxPerWorld = disp / _maxExtent;
var roadWidthWorld = RoadHalfWidth * 2f;
var lengthPx = MathF.Max( roadWidthWorld * pxPerWorld * 1.4f, 14f );
foreach ( var cp in Scene.GetAll<Checkpoint>() )
{
if ( !cp.IsValid() )
continue;
var n = WorldToNorm( cp.WorldPosition );
var sx = half + (n.x - center.x) * disp;
var sy = half + (n.y - center.y) * disp;
// Bar is perpendicular to the line tangent.
float angle = 0f;
if ( line is not null && line.IsValid )
{
var d = line.GetDistanceAtPosition( cp.WorldPosition );
var tan = line.GetTangentAtDistance( d );
// North-up map: screenX ~ -worldY, screenY ~ -worldX.
var tsx = -tan.y;
var tsy = -tan.x;
// Perpendicular = bar's long axis.
angle = MathF.Atan2( tsx, -tsy ) * (180f / MathF.PI);
}
list.Add( new CpMarker
{
X = sx,
Y = sy,
AngleDeg = angle,
LengthPx = lengthPx,
IsFinish = cp.IsFinishLine
} );
}
return list;
}
private struct BlipDot
{
public float X;
public float Y;
public Color Color;
public string Class;
public int Priority;
}
private List<BlipDot> GetBlipDots( Vector2 center, float disp )
{
var dots = new List<BlipDot>();
var half = PanelSize * 0.5f;
var maxR = half - 8f; // keep dots inside the round clip
// Off-screen blips clamp to the rim.
Vector2 Project( Vector3 world )
{
var n = WorldToNorm( world );
var off = new Vector2( (n.x - center.x) * disp, (n.y - center.y) * disp );
if ( off.Length > maxR )
off = off.Normal * maxR;
return new Vector2( half + off.x, half + off.y );
}
// GetAllComponents returns only enabled components.
foreach ( var blip in Scene.GetAllComponents<IMinimapBlip>() )
{
if ( blip is not Component c || !c.IsValid() || !blip.ShowOnMinimap )
continue;
var p = Project( c.WorldPosition );
dots.Add( new BlipDot
{
X = p.x,
Y = p.y,
Color = blip.BlipColor,
Class = blip.BlipClass,
Priority = blip.BlipPriority
} );
}
// Draw order: ghost < items < racers < local player.
dots.Sort( ( x, y ) => x.Priority.CompareTo( y.Priority ) );
return dots;
}
/// <summary>
/// Normalized [0,1] texel coords for a world position (north-up).
/// </summary>
private Vector2 WorldToNorm( Vector3 w )
{
// North-up: world +X = screen up, world +Y = screen left.
var nu = 0.5f - (w.y - _center.y) / _maxExtent;
var nv = 0.5f - (w.x - _center.x) / _maxExtent;
return new Vector2( nu, nv );
}
protected override int BuildHash()
{
return HashCode.Combine( _texture != null, Time.Now );
}
}
@using Sandbox;
@using Sandbox.UI;
@using Machines.Player;
@using Machines.Components;
@using Machines.GameModes;
@namespace Machines.UI
@inherits Panel
<root class="score-hud">
@{
var car = VisibleCar();
if ( car is not null )
{
var score = car.Score;
<div class="score-stack" style="opacity: @ComboAlpha( score ).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );">
<div class="combo-value">@score.ComboScore</div>
@foreach ( var entry in score.Entries.OrderByDescending( e => e.LastTime ) )
{
<div class="combo-entry">
<span class="entry-source">@entry.Source</span>
<span class="entry-amount">+@entry.Amount</span>
</div>
}
</div>
}
}
</root>
@code
{
// Tail of the combo timeout over which the whole stack fades out (seconds).
private const float FadeTail = 0.4f;
/// <summary>
/// Local car to show, only while a combo is running; null hides the overlay (so it never
/// shows on start, and disappears once the chain lapses). Mirrors CarBoostHud's gating.
/// </summary>
private Car VisibleCar()
{
if ( !BaseGameMode.Current.IsValid() || BaseGameMode.Current.State != GameModeState.Playing )
return null;
if ( Game.ActiveScene?.Get<SpectatorCamera>()?.IsActive ?? false )
return null;
var car = Car.Local;
if ( !car.IsValid() || car.Autopilot )
return null;
if ( !car.Score.IsValid() || !car.Score.IsComboActive )
return null;
return car;
}
// Full opacity until the last FadeTail seconds of the combo, then ramp to zero.
private static float ComboAlpha( CarScore score )
{
var remaining = CarScore.ComboTimeout - score.SinceLastGain;
return MathX.Clamp( remaining / FadeTail, 0f, 1f );
}
protected override int BuildHash() => HashCode.Combine( Time.Now );
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root class="tt">
<div class="icon">@Icon</div>
<div class="title">@Title</div>
<div class="description">@Description</div>
</root>
@code
{
[Parameter] public string Title { get; set; }
[Parameter] public string Icon { get; set; }
[Parameter] public string Description { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox
<root>
<Button class="menu-action primary wide" Icon="💾" Text="#spawnmenu.dupes.save_button" Disabled=@( !CanSaveDupe() ) @onclick=@MakeSave></Button>
</root>
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox
<root>
<div class="title">@Item.Title</div>
<div class="author">
<div class="avatar" style="background-image: url('@Item.Owner.Avatar');"></div>
<div class="name">@Item.Owner.Name</div>
</div>
</root>
@code
{
public Storage.QueryItem Item { get; set; }
public override bool WantsDrag => true;
protected override void OnParametersSet()
{
Style.SetBackgroundImage( Item.Preview );
}
protected override void OnMouseDown( MousePanelEvent e )
{
if ( e.MouseButton == MouseButtons.Right )
{
var menu = MenuPanel.Open( this );
SpawnlistData.PopulateContextMenu( menu, new SpawnlistItem
{
Ident = SpawnlistItem.MakeIdent( "dupe", Item.Id.ToString(), "workshop" ),
Title = Item.Title,
Icon = Item.Preview,
} );
return;
}
base.OnMouseDown( e );
}
protected override async void OnDragStart( DragEvent e )
{
var data = new DragData
{
Type = "dupe",
Icon = Item.Preview,
Title = Item.Title,
Source = this
};
DragHandler.StartDragging( data );
var installed = await Item.Install();
if ( installed is null ) return;
data.Data = installed.Files.ReadAllText( "/dupe.json" );
}
protected override void OnDragEnd(DragEvent e)
{
if ( DragHandler.IsDragging )
{
DragHandler.StopDragging();
}
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
@if ( LastResult is null )
{
// loading
return;
}
<SpawnMenuContent>
<Header>
<SpawnMenuToolbar>
<Left>
<TextEntry Placeholder="#spawnmenu.common.search" class="filter menu-input" Value:bind=@Filter />
</Left>
<Right>
<DropDown Value:bind=@SortOrder />
</Right>
</SpawnMenuToolbar>
</Header>
<Body>
<VirtualGrid Items=@( LastResult.Packages ) ItemSize=@(120)>
<Item Context="item">
@if (item is Package entry)
{
<SpawnMenuIcon Ident=@($"prop:{entry.FullIdent}") Title="@entry.Title"></SpawnMenuIcon>
}
</Item>
</VirtualGrid>
</Body>
</SpawnMenuContent>
@code
{
public string Category { get; set; } = "";
private string Filter
{
get;
set { field = value; Rebuild(); }
}
public PackageSortMode SortOrder
{
get;
set { field = value; Rebuild(); }
}
protected override void OnParametersSet()
{
SortOrder = PackageSortMode.Popular;
Rebuild();
}
Package.FindResult LastResult;
async void Rebuild()
{
var query = $"sort:{SortOrder.ToIdentifier()} type:model";
if ( !string.IsNullOrEmpty( Filter ) ) query += $" {Filter} ";
if ( !string.IsNullOrEmpty( Category ) ) query += $" category:{Category}";
LastResult = await Package.FindAsync( query );
StateHasChanged();
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
@{
var data = GetData();
}
<SpawnMenuContent>
<Header>
<SpawnMenuToolbar>
<Left>
<h2>@data.Name</h2>
</Left>
<Right>
@{
var workshopId = Entry.GetMeta( "_workshopId", 0u );
var isPublished = workshopId > 0;
}
<div class="menu-icon-toggle-group">
@if ( isPublished )
{
<IconPanel Tooltip="#spawnmenu.spawnlist.copy_url" Text="link" @onclick=@( () => Clipboard.SetText( $"https://steamcommunity.com/sharedfiles/filedetails/?id={workshopId}" ) ) />
<IconPanel Tooltip="#spawnmenu.spawnlist.sync" Text="sync" @onclick=@( () => OnPublish() ) />
}
else
{
<IconPanel Tooltip="#spawnmenu.spawnlist.publish" Text="cloud_upload" @onclick=@( () => OnPublish() ) />
}
@if ( !Entry.Files.IsReadOnly )
{
<IconPanel Tooltip="#spawnmenu.spawnlist.delete" Text="delete" @onclick=@( () => OnDelete() ) />
}
else
{
<IconPanel Tooltip="#spawnmenu.spawnlist.remove" Text="delete" @onclick=@( () => OnUninstall() ) />
}
</div>
</Right>
</SpawnMenuToolbar>
</Header>
<Body>
@if ( data.Items.Count == 0 )
{
<div class="empty-state">
<p>#spawnmenu.spawnlist.empty_title</p>
<p>#spawnmenu.spawnlist.empty_instructions</p>
</div>
}
else
{
<VirtualGrid [email protected] ItemSize=@(120)>
<Item Context="item">
@if ( item is SpawnlistItem spawnItem )
{
<SpawnMenuIcon Ident="@spawnItem.Ident" Title="@spawnItem.Title" Icon="@spawnItem.Icon" />
}
</Item>
</VirtualGrid>
}
</Body>
</SpawnMenuContent>
@code
{
public Storage.Entry Entry { get; set; }
SpawnlistData _cachedData;
RealTimeSince _lastRefresh;
SpawnlistData GetData()
{
if ( _cachedData == null )
RefreshCache();
return _cachedData;
}
public void RefreshCache()
{
_cachedData = SpawnlistData.Load( Entry );
_lastRefresh = 0;
}
public override void Tick()
{
base.Tick();
// Periodically check for changes from other tabs
if ( _lastRefresh > 1f )
{
var fresh = SpawnlistData.Load( Entry );
if ( fresh.Items.Count != _cachedData?.Items?.Count )
{
_cachedData = fresh;
StateHasChanged();
}
_lastRefresh = 0;
}
}
protected override int BuildHash() => HashCode.Combine( _cachedData?.Items?.Count );
void OnPublish()
{
if ( Entry is null ) return;
SpawnlistData.Publish( Entry );
}
void OnDelete()
{
if ( Entry is null ) return;
var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
page?.Collection.Delete( Entry );
}
void OnUninstall()
{
if ( Entry is null ) return;
var workshopId = Entry.GetMeta( "_workshopId", 0ul );
var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
page?.Collection.Uninstall( workshopId );
}
}
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel
<root>
<div class="menu-segmented-group">
@{
var activeMode = SpawnMenuHost.GetActiveMode();
foreach ( var mode in Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>().OrderBy( x => x.Type.Order ) )
{
if ( !mode.Attribute.CheckCondition() ) continue;
var activeClass = mode.Type.TargetType == activeMode?.GetType() ? "active" : "";
<div class="menu-mode-button @activeClass" @onclick="@( () => SpawnMenuHost.SwitchMode( mode.Type.Name ) )">
<div class="icon">@mode.Type.Icon</div>
<div class="title">@mode.Type.Title</div>
</div>
}
}
</div>
</root>
@code
{
protected override int BuildHash() => HashCode.Combine( SpawnMenuHost.GetActiveMode(), Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>() );
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits PanelComponent
<root>
@if ( Loot.IsValid() )
{
<div class="container">
<span>@Loot.FullName</span>
<div class="row">
<span class="currency">$</span><span>@($"{Loot.MonetaryValue:n0}")</span>
</div>
</div>
}
</root>
@code {
private Loot loot;
private Loot Loot => loot ??= GetComponentInParent<Loot>();
protected override int BuildHash() => HashCode.Combine( Loot?.FullName, Loot?.MonetaryValue ?? 0 );
}
<style>
GroundLootPanel {
transition: transform 0.5s ease-in-out;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
.container {
padding: 10px;
padding-right: 30px;
padding-left: 30px;
font-size: 32px;
align-items: center;
font-family: "alagard";
flex-direction: column;
color: white;
text-shadow: 3px 3px 0px black;
background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.5) 20%, rgba(black, 0.5) 80%, rgba(black, 0) 100%);
.currency {
padding-right: 4px;
font-size: 22px;
top: 5px;
color: rgba(50, 205, 50, 1);
}
}
&:outro {
transform: scale(0);
}
&:intro {
transform: scale(0);
}
}
</style>
@using System
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits PanelComponent
<root>
@{
var player = Player.Local;
var isSpectator = player?.Spectating ?? false;
var isPlayer = player?.IsValid() ?? false;
}
<MoneyCounter />
<Eventlog />
<PlayerCounter />
<StunIndicator />
<QuickPing />
<SubtitlesList />
<Lockpicker />
<BlackScreen />
<div class="input-hints">
@if ( isSpectator )
{
<div class="hint"><inputglyph action="StopFollowing"/>Stop following</div>
<div class="hint"><inputglyph action="FollowNext"/>Next player</div>
<div class="hint"><inputglyph action="FollowPrevious"/>Previous player</div>
}
else
{
<div class="hint"><inputglyph action="inventory"/>Inventory</div>
<div class="hint"><inputglyph action="chat"/>Chat</div>
<div class="hint"><inputglyph action="ping"/>Quick Ping</div>
}
</div>
@if ( player.IsValid() )
{
<Inventory />
<Shop />
<TextInput Ghost="Say something..." class="chat-input" @ref="ChatInput" onsubmit=@OnChatSubmit />
@if ( !player.SeenTips )
{
<TutorialTips />
}
}
@if ( player.IsValid() && player.IsAlive && !LockpickerBus.IsOpen )
{
<div class="crosshair">
<div class="mark">+</div>
<RadialProgress />
<InteractionTip />
</div>
}
@if ( player.IsValid() && !player.IsAlive )
{
<div class="death">
<span class="title">YOU'RE DEAD!</span>
</div>
}
</root>
@code {
private TextInput ChatInput { get; set; }
protected override void OnUpdate()
{
if ( ChatInput is null )
return;
if ( Input.Pressed( "chat" ) )
{
ChatInput.Focus();
ChatInput.AddClass( "visible" );
}
}
private void OnChatSubmit()
{
if ( ChatInput is null )
return;
var text = ChatInput.Text;
if ( !string.IsNullOrWhiteSpace( text ) )
Player.Local?.Say( text );
ChatInput.Text = "";
ChatInput.RemoveClass( "visible" );
}
protected override int BuildHash() => HashCode.Combine(
Player.Local,
Player.Local?.IsAlive ?? false,
LockpickerBus.IsOpen );
}
<style>
Hud {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
font-family: "alagard";
justify-content: flex-start;
.crosshair {
position: absolute;
top: 0; left: 0;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
.mark {
color: white;
font-size: 28px;
text-shadow: 2px 2px 0px black;
}
}
.input-hints {
position: absolute;
bottom: 30px;
right: 0px;
align-items: flex-end;
flex-direction: column;
z-index: 3;
.hint {
color: white;
height: 32px;
justify-content: center;
font-size: 32px;
padding-left: 50px;
padding-top: 10px;
padding-bottom: 40px;
padding-right: 10px;
background: linear-gradient(to left, rgba(black, 0.5) 0%, rgba(black, 0.2) 75%, rgba(black, 0) 100%);
margin-top: 5px;
text-shadow: 3px 3px 0px black;
InputGlyph {
width: 32px;
aspect-ratio: 1;
margin-right: 10px;
}
}
}
.chat-input {
position: absolute;
width: 450px;
min-height: 20px;
font-size: 24px;
opacity: 0;
pointer-events: all;
background-color: rgba(37, 64, 98, 1);
box-shadow: 3px 3px 0px 0px rgba(17, 44, 78, 1);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
transform: translate(-50% 200px);
text-shadow: 3px 3px 0px black;
left: 50%;
top: 50%;
margin-top: 40px;
&.visible {
opacity: 1;
transform: translate(-50% 0px);
}
}
.death {
position: absolute;
width: 100%;
height: 100%;
left: 0px;
top: 0px;
color: white;
font-size: 32px;
z-index: 2;
backdrop-filter: grayscale(100%);
justify-content: center;
transition: opacity 1s ease-in-out;
opacity: 0;
text-shadow: 4px 4px 0px black;
&.visible {
opacity: 1;
}
.container {
flex-direction: column;
align-items: center;
}
span {
background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.4) 20%, rgba(black, 0.4) 80%, rgba(black, 0) 100%);
padding: 10px;
padding-left: 40px;
padding-right: 40px;
}
.title {
color: red;
font-size: 64px;
margin-top: 150px;
margin-bottom: 20px;
}
}
}
</style>
@using System
@using Sandbox
@using Sandbox.UI
@using WorldPanel = Sandbox.WorldPanel
@namespace BrickJam.UI
@inherits PanelComponent
<root>
@if ( !IsLocal && Player.IsValid() && Player.IsAlive )
{
<div class="container">
<span>@Name</span>
</div>
}
</root>
@code {
private Player player;
private Player Player => player ??= GetComponentInParent<Player>();
// Don't draw your own nametag in first person.
private bool IsLocal => Player == Player.Local;
private string Name => Player.IsValid() && Player.Network.Owner is { } owner ? owner.DisplayName : "player";
protected override int BuildHash() => HashCode.Combine( IsLocal, Name, Player?.IsAlive ?? false );
}
<style>
Nametag {
transition: transform 0.5s ease-in-out;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
.container {
padding: 10px;
padding-right: 30px;
padding-left: 30px;
font-size: 32px;
align-items: center;
font-family: "alagard";
flex-direction: column;
color: white;
text-shadow: 3px 3px 0px black;
background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.5) 20%, rgba(black, 0.5) 80%, rgba(black, 0) 100%);
}
&:outro {
transform: scale(0);
}
&:intro {
transform: scale(0);
}
}
</style>
@using System
@using System.Linq
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits Panel
<root>
@foreach ( var line in lines )
{
<div class="subtitle">
@if ( !string.IsNullOrEmpty( line.Speaker ) )
{
<span class="speaker">@line.Speaker:</span>
}
<span class="text">@line.Text</span>
</div>
}
</root>
@code {
private readonly List<(string Speaker, string Text, TimeUntil Expire)> lines = new();
public SubtitlesList()
{
SubtitleBus.OnSubtitle += Add;
}
public override void OnDeleted()
{
base.OnDeleted();
SubtitleBus.OnSubtitle -= Add;
}
private void Add( string speaker, string text, float duration )
{
lines.Add( (speaker, text, duration) );
if ( lines.Count > 3 )
lines.RemoveAt( 0 );
}
public override void Tick()
{
lines.RemoveAll( l => l.Expire );
}
protected override int BuildHash() => HashCode.Combine( lines.Count, lines.LastOrDefault().Text );
}
<style>
SubtitlesList {
position: absolute;
left: 0; right: 0;
bottom: 60px;
flex-direction: column;
align-items: center;
.subtitle {
flex-direction: row;
padding: 6px 16px;
margin-top: 4px;
background-color: rgba(0,0,0,0.6);
font-size: 26px;
text-shadow: 2px 2px 0px black;
.speaker { color: rgba(255,220,80,1); margin-right: 8px; }
.text { color: white; }
}
}
</style>
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
@if(_active)
{
<div class="survival-tut-bg"></div>
<div class="survival-tut-card">
<div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
<div class="survival-tut-title">@_steps[_step].Title</div>
<div class="survival-tut-text">@_steps[_step].Text</div>
@if(_steps[_step].ShowGridDemo)
{
<div class="survival-demo-grid">
...
</div>
<div class="survival-demo-caption">@_demoCaption</div>
}
<button class="survival-tut-close" onclick=@NextStep>
@(_step == _steps.Length - 1 ? "▶ Let's Survive!" : "Got it →")
</button>
</div>
}
</root>
@code {
public static SurvivalTutorial Instance { get; private set; }
public bool IsActive => _active;
bool _active = false;
int _step = 0;
struct SurvivalTutStep
{
public string Title;
public string Text;
public bool ShowGridDemo;
}
static readonly SurvivalTutStep[] _steps = new SurvivalTutStep[]
{
new SurvivalTutStep {
Title = "Welcome to Survival! ⚡",
Text = "No waves here — chests spawn continuously and slide downward. Your goal: last as long as possible.",
ShowGridDemo = false
},
new SurvivalTutStep {
Title = "It Gets Faster... 🔥",
Text = "Chests spawn every couple seconds at first. After about a minute, spawn rate speeds up and more chests appear at once. Stay sharp — it never really stops.",
ShowGridDemo = false
},
new SurvivalTutStep {
Title = "Block the Slide! 🛡️",
Text = "Here's the key trick: a placed bomb blocks a chest from sliding into its column. Use this to buy time and set up bigger chain reactions before detonating.",
ShowGridDemo = true
},
new SurvivalTutStep {
Title = "Survive as Long as You Can 💀",
Text = "One chest reaching the bottom row ends your run. Place smart, chain big, and see how long you can hold on!",
ShowGridDemo = false
},
};
// ── Mini grid demo animation state ──
float _demoTimer = 0f;
int _demoPhase = 0; // 0=chest falling, 1=bomb placed blocks it, 2=hold
string _demoCaption = "A chest is about to slide down...";
protected override void OnStart()
{
Instance = this;
_active = false;
}
public void Activate()
{
_step = 0;
_active = true;
_demoPhase = 0;
_demoTimer = 0f;
StateHasChanged();
}
protected override void OnUpdate()
{
if (!_active || !_steps[_step].ShowGridDemo) return;
_demoTimer += Time.Delta;
// Simple 3-phase loop: chest falls (0-1.2s) -> bomb blocks it (1.2-2.4s) -> hold (2.4-3.6s) -> reset
if (_demoTimer < 1.2f)
{
_demoPhase = 0;
_demoCaption = "A chest slides down each cycle...";
}
else if (_demoTimer < 2.4f)
{
_demoPhase = 1;
_demoCaption = "...but a placed bomb blocks it from moving further!";
}
else if (_demoTimer < 3.6f)
{
_demoPhase = 2;
_demoCaption = "Now you have time to plan your chain reaction.";
}
else
{
_demoTimer = 0f;
}
StateHasChanged();
}
// Simple fixed layout: chest at (0,1) falling toward bomb at (2,1)
string GetDemoCellClass(int r, int c)
{
if (c != 1) return "empty";
if (_demoPhase == 0)
{
// chest animates from row 0 toward row 1
if (r == 0) return "chest";
if (r == 2) return "bomb";
return "empty";
}
else
{
// blocked — chest sits right above the bomb, doesn't pass
if (r == 1) return "chest";
if (r == 2) return "bomb";
return "empty";
}
}
string GetDemoCellIcon(int r, int c)
{
var cls = GetDemoCellClass(r, c);
return cls switch {
"chest" => "🎁",
"bomb" => "💣",
_ => ""
};
}
void NextStep()
{
_step++;
Sound.Play("UI_Button");
if (_step >= _steps.Length)
{
_active = false;
ChainReactionGame.Instance?.UnfreezeSurvivalSpawn(); // new public method
StateHasChanged();
return;
}
_demoTimer = 0f;
_demoPhase = 0;
StateHasChanged();
}
protected override int BuildHash() =>
System.HashCode.Combine(_active, _step, _demoPhase);
}@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@implements ILocalPlayerEvent
<root>
@for (int i = 0; i < 5; i++)
{
@if ( ItemsInSlot(i).Count() < 1 )
{
continue;
}
<InventorySlot Index=@i Inventory=@inventory Hovered=@hovered Active=@active></InventorySlot>
}
</root>
@code
{
[Property] public SoundEvent SwitchSound { get; set; }
[Property] public SoundEvent SelectSound { get; set; }
[Property] public SoundEvent CancelSound { get; set; }
PlayerInventory inventory => Player.Local.IsValid() ? Player.Local.Inventory : null;
Carryable hovered;
Carryable active;
Carryable prev;
protected override int BuildHash() => HashCode.Combine(inventory, hovered, active);
IEnumerable<Carryable> ItemsInSlot( int slot )
{
return inventory.IsValid() ? inventory.Carryables.Where(x => x.PreferredSlot == slot) : [];
}
void ILocalPlayerEvent.OnPickup( BaseInventoryItem weapon )
{
StateHasChanged();
}
protected override void OnUpdate()
{
DoInventoryInput();
}
RealTimeSince timeSinceInteraction;
void DoInventoryInput()
{
if (inventory is null)
return;
if (GameManager.Current.CurrentGameStage != GameManager.GameStage.Game)
{
if (hovered is not null)
{
hovered = null;
active = null;
prev = null;
}
return;
}
MoveSlot(-(int)Input.MouseWheel.y);
if ( Input.Pressed( "Menu" ) && prev.IsValid() )
{
var weapon = prev;
prev = inventory.Current;
inventory.SwitchWeapon( weapon );
}
if (Input.Pressed("SlotNext")) MoveSlot(1);
if (Input.Pressed("SlotPrev")) MoveSlot(-1);
if (Input.Pressed("Slot1")) IterateSlot(0);
if (Input.Pressed("Slot2")) IterateSlot(1);
if (Input.Pressed("Slot3")) IterateSlot(2);
if (Input.Pressed("Slot4")) IterateSlot(3);
if (Input.Pressed("Slot5")) IterateSlot(4);
if (hovered is null)
return;
if (Input.Pressed("Attack1"))
{
Input.ReleaseAction("Attack1");
Input.SetAction("Attack1", false);
prev = inventory.Current;
inventory.SwitchWeapon(hovered);
active = hovered;
hovered = null;
Sound.Play(SelectSound);
}
if (Input.Pressed("Attack2") || timeSinceInteraction > 2f)
{
Input.ReleaseAction("Attack2");
Input.SetAction("Attack2", false);
active = null;
hovered = null;
Sound.Play(CancelSound);
}
}
public void MoveSlot( int delta )
{
if ( delta == 0 )
return;
var weapons = inventory.Carryables.Where( x => x.CanSwitch() ).ToList();
if ( weapons.Count == 0 )
return;
var currentHover = hovered ?? active ?? inventory.Current ?? weapons.FirstOrDefault();
int currentIndex = weapons.IndexOf( currentHover );
currentIndex += delta;
currentIndex %= weapons.Count;
if ( currentIndex < 0 )
currentIndex = weapons.Count + currentIndex;
active = null;
if ( GamePreferences.FastSwitch )
{
inventory.SwitchWeapon( weapons[currentIndex] );
}
else
{
hovered = weapons[currentIndex];
timeSinceInteraction = 0;
}
Sound.Play( SwitchSound );
}
public void IterateSlot( int slot )
{
var slotWeapons = inventory.Carryables.Where( x => x.PreferredSlot == slot ).Where( x => x.CanSwitch() ).OrderBy( x => x.SlotOrder ).ToList();
if ( slotWeapons.Count == 0 )
return;
// if inventory wasn't open, and current weapon is in a different slot, currentIndex will be -1 (incremented to 0), and first weapon in target slot will be selected
var currentHover = hovered ?? active ?? inventory.Current ?? slotWeapons.FirstOrDefault();
int currentIndex = slotWeapons.IndexOf( currentHover );
currentIndex += 1;
currentIndex %= slotWeapons.Count;
if ( currentIndex < 0 )
currentIndex = slotWeapons.Count + currentIndex;
active = null;
if ( GamePreferences.FastSwitch )
{
inventory.SwitchWeapon( slotWeapons[currentIndex] );
}
else
{
hovered = slotWeapons[currentIndex];
timeSinceInteraction = 0;
}
Sound.Play( SwitchSound );
}
}
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]
@*
The live readout: score and best, and nothing else. Deliberately small - everything else
the player needs during a run is in the arena itself, and length lives in the game-over
summary instead of here because it is interesting after a run and noise during one.
Refreshes itself off its own Tick(), independently of whichever overlay GameUi has on top
of it, so a paused or finished run still shows a live-looking score without dragging the
rest of the interface through a rebuild to do it.
*@
<root class="readout">
<div class="@ScoreClass">@ScoreText</div>
<div class="best">
<span class="best-label">BEST</span>
<span class="best-value">@BestText</span>
</div>
</root>
@code
{
[Parameter] public GameSession Session { get; set; }
private string ScoreText { get; set; } = "0";
private string BestText { get; set; } = "0";
/// <summary>Carries the pop class while the score is counting, built here rather than in
/// markup so the attribute is never a mix of literal text and an @@expression.</summary>
private string ScoreClass { get; set; } = "score";
/// <summary>
/// The score as it is being shown, which chases the real one rather than jumping to it.
/// A number that counts up turns a state change into an event the eye can follow, and it
/// is the difference between the score being a readout and the score being the reward.
/// Kept as a float so the chase is smooth, and always floored so the player never sees a
/// number higher than what they have actually earned.
/// </summary>
private float shownScore;
private int lastRealScore;
/// <summary>Seconds since the score last changed, driving the pop.</summary>
private float scoreAge = 99f;
public override void Tick()
{
base.Tick();
var run = Session?.Run;
if ( run is null ) return;
TickScore( run.Score );
ScoreText = ((int)shownScore).ToString();
BestText = (Session.Records?.BestScore ?? 0).ToString();
}
/// <summary>
/// Advances the displayed score towards the real one and drives the pop.
/// <para>
/// A restart drops the target to zero, and that is the one case where counting is wrong -
/// watching your score tick down to nothing is a strange thing to do to somebody who just
/// asked to play again, so it snaps instead.
/// </para>
/// </summary>
private void TickScore( int real )
{
if ( real != lastRealScore )
{
if ( real < lastRealScore ) shownScore = real;
lastRealScore = real;
scoreAge = 0f;
}
scoreAge += Time.Delta;
// Rate comes from the size of the gap, so an award of any size lands in about the same
// time rather than a big one taking noticeably longer.
var gap = MathF.Abs( real - shownScore );
if ( gap > 0.01f )
{
var rate = MathF.Max( gap, 1f ) / GameConfig.ScoreCountDuration;
shownScore = shownScore < real
? MathF.Min( shownScore + rate * Time.Delta, real )
: MathF.Max( shownScore - rate * Time.Delta, real );
}
else
{
shownScore = real;
}
ScoreClass = scoreAge < GameConfig.ScoreCountDuration ? "score popped" : "score";
}
/// <summary>
/// The counting score is folded in as its whole number, so the panel redraws once per
/// digit change rather than every frame.
/// </summary>
protected override int BuildHash() => System.HashCode.Combine( (int)shownScore, ScoreClass, BestText );
}
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]
@*
The title screen. A keyboard player can skip it entirely by just pressing a direction -
GameSession takes that as both "start" and the first turn - so the buttons here exist for
the mouse player and for reaching Settings, not because the game waits on them.
*@
<root class="scrim">
<div class="card">
<div class="wordmark">Coilgarden</div>
<div class="tagline">A small garden. One apple at a time.</div>
<div class="menu">
<div [email protected] class="btn primary" onclick=@Play>Play</div>
<div [email protected] class="btn" onclick=@OpenSettingsClicked>Settings</div>
</div>
<div class="prompt">or press a direction to begin</div>
@if ( HasRecord )
{
<div class="records">
<div class="records-title">Best run</div>
<div class="record-row"><span>Score</span><span>@BestScoreText</span></div>
<div class="record-row"><span>Length</span><span>@BestLengthText</span></div>
<div class="record-row"><span>Runs played</span><span>@RunsText</span></div>
</div>
}
</div>
</root>
@code
{
[Parameter] public GameSession Session { get; set; }
[Parameter] public Action SettingsRequested { get; set; }
private HighScoreData Records => Session?.Records;
private bool HasRecord => Records is { RunsPlayed: > 0 };
private string BestScoreText => (Records?.BestScore ?? 0).ToString();
private string BestLengthText => (Records?.BestLength ?? 0).ToString();
private string RunsText => (Records?.RunsPlayed ?? 0).ToString();
private void Play()
{
UiSound.Click();
Session?.StartRun();
}
private void OpenSettingsClicked()
{
UiSound.Click();
SettingsRequested?.Invoke();
}
protected override int BuildHash() => System.HashCode.Combine(
Records?.BestScore, Records?.BestLength, Records?.RunsPlayed );
}
@namespace Sunless.Libraries.UI
@using Sandbox
@using Sandbox.UI
@attribute [StyleSheet]
@inherits Panel
<root class="modal-overlay @(IsOpen ? "is-open" : "") @ExtraClass">
@if ( IsOpen )
{
<div class="modal-scrim" @onclick=@HandleScrimClick></div>
<div class="modal-dialog" style=@DialogStyle>
@if ( Header is not null || !string.IsNullOrEmpty( Title ) || ShowClose )
{
<div class="modal-header">
@if ( Header is not null )
{
@Header
}
else
{
<div class="modal-title">@Title</div>
}
@if ( ShowClose )
{
<div class="modal-close" @onclick=@HandleClose>X</div>
}
</div>
}
@if ( Body is not null )
{
<div class="modal-body">@Body</div>
}
@if ( Footer is not null )
{
<div class="modal-footer">@Footer</div>
}
</div>
}
</root>
@code
{
public bool IsOpen { get; set; }
public string Title { get; set; }
public bool ShowClose { get; set; } = true;
public bool DismissOnScrim { get; set; } = true;
public string ExtraClass { get; set; }
public string DialogStyle { get; set; }
public Action OnClose { get; set; }
public RenderFragment Header { get; set; }
public RenderFragment Body { get; set; }
public RenderFragment Footer { get; set; }
void HandleClose()
{
try { OnClose?.Invoke(); } catch ( Exception e ) { Log.Warning( $"[Modal] OnClose threw: {e.Message}" ); }
}
void HandleScrimClick()
{
if ( !DismissOnScrim ) return;
HandleClose();
}
protected override int BuildHash() => HashCode.Combine( IsOpen, Title, ShowClose, ExtraClass, DialogStyle );
}
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox
@*
IMPORTANT — s&box text rendering quirk.
Razor compiles a literal containing non-ASCII characters (emoji, ×, ·, —)
into AddMarkupContent, and plain-ASCII literals into AddContent. The panel
renderer hoists every AddMarkupContent node ahead of every AddContent node,
so "@A × @B" comes out as "×AB".
The rule this file follows: an element holds EITHER pure literal text OR a
single @expression. Anything composed goes through a helper below.
*@
<root>
@if ( Visible )
{
<div class="idle-root">
@* ══════════════════════ AMBIENT BACKDROP ══════════════════════ *@
<div class="idle-bg"></div>
<div class="idle-bg-glow @GlowClass()"></div>
@for ( int i = 0; i < 12; i++ )
{
float pLeft = (i * 8.3f) % 100f;
float pDur = 10f + (i * 0.9f) % 8f;
float pDel = (i * 1.4f) % 12f;
int pSz = (i % 4 == 0) ? 4 : 2;
string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.30)";
<div class="idle-particle"
style="left:@pLeft.ToString("F1")%;width:@(pSz)px;height:@(pSz)px;background-color:@pCol;animation-duration:@pDur.ToString("F1")s;animation-delay:@pDel.ToString("F1")s;"></div>
}
@* ══════════════════════════ TOP BAR ══════════════════════════ *@
<div class="idle-top">
<button class="idle-back" onclick=@OnBack>⌂</button>
<div class="idle-brand">
<div class="idle-brand-title">THE REACTOR</div>
<div class="idle-brand-sub">chain reactions, forever</div>
</div>
<div class="idle-currencies">
<div class="idle-cur cores">
<span class="idle-cur-icon">⚛️</span>
<span class="idle-cur-val">@CoresLabel()</span>
<span class="idle-cur-lbl">Cores</span>
</div>
<div class="idle-cur rate">
<span class="idle-cur-icon">📈</span>
<span class="idle-cur-val">@RateLabel()</span>
<span class="idle-cur-lbl">per sec</span>
</div>
<div class="idle-cur flux">
<span class="idle-cur-icon">🔷</span>
<span class="idle-cur-val">@FluxLabel()</span>
<span class="idle-cur-lbl">Flux</span>
</div>
<div class="idle-cur frag">
<span class="idle-cur-icon">💠</span>
<span class="idle-cur-val">@FragLabel()</span>
<span class="idle-cur-lbl">Frags</span>
</div>
</div>
</div>
@* ═══════════════════════════ BODY ════════════════════════════ *@
@* Hidden behind a modal — the panel renderer draws emoji glyphs above
overlay panels, so the board has to actually leave the tree. *@
@if ( !ModalOpen )
{
<div class="idle-body">
@* ─────────────────────── LEFT COLUMN ─────────────────────── *@
<div class="idle-col idle-left">
<div class="idle-card">
<div class="idle-card-hd">OUTPUT</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Cores / sec</span>
<span class="idle-stat-val hot">@RateLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Global multiplier</span>
<span class="idle-stat-val">@GlobalMultLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Chain exponent</span>
<span class="idle-stat-val">@ExponentLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Last chain</span>
<span class="idle-stat-val">@LastChainLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Best chain</span>
<span class="idle-stat-val">@BestChainLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Board</span>
<span class="idle-stat-val">@BoardLabel()</span>
</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Feed line</span>
<span class="idle-stat-val @FeedClass()">@FeedLabel()</span>
</div>
</div>
@if ( ShowBoosts )
{
<div class="idle-card">
<div class="idle-card-hd">ACTIVE</div>
@if ( R.SurgeActive )
{
<div class="idle-boost surge">
<span class="idle-boost-icon">⚡</span>
<span class="idle-boost-lbl">@SurgeBoostLabel()</span>
<span class="idle-boost-time">@SecondsLabel( R.SurgeTimeLeft )</span>
</div>
}
@foreach ( var boost in R.Boosts )
{
var b = boost;
<div class="idle-boost">
<span class="idle-boost-icon">@b.Icon</span>
<span class="idle-boost-lbl">@BoostLabel( b )</span>
<span class="idle-boost-time">@SecondsLabel( b.SecondsLeft )</span>
</div>
}
</div>
}
<div class="idle-card">
<div class="idle-card-hd">CONDENSER</div>
<div class="idle-bar-track">
<div class="idle-bar-fill frag" style="width:@Pct( R.FragmentProgressFraction )%;"></div>
</div>
<div class="idle-card-note">@CondenserLabel()</div>
<div class="idle-stat-row">
<span class="idle-stat-lbl">Today</span>
<span class="idle-stat-val @CapClass()">@FragTodayLabel()</span>
</div>
</div>
<div class="idle-card">
<div class="idle-card-hd">MANIFEST</div>
@foreach ( var t in IdleBalance.Tiers )
{
var tier = t;
bool got = S.CycleCores >= tier.Requires;
<div class="idle-tier @(got ? "on" : "")">
<span class="idle-tier-icon">@tier.Icon</span>
<div class="idle-tier-body">
<div class="idle-tier-name">@tier.Name</div>
<div class="idle-tier-blurb">@TierBlurb( tier, got )</div>
</div>
</div>
}
</div>
@* Rank card — neighbours, not a top-N. See RebuildRankRows(). *@
<div class="idle-card">
<div class="idle-card-hd">REACTOR RANK</div>
@if ( _rankRows.Count == 0 )
{
<div class="idle-card-note">@RankEmptyLabel()</div>
}
else
{
@foreach ( var row in _rankRows )
{
var rw = row;
<div class="idle-rank-row @RankRowClass( rw )">
<span class="idle-rank-pos">@rw.RankLabel</span>
<span class="idle-rank-name">@rw.Name</span>
<span class="idle-rank-val">@rw.ValueLabel</span>
</div>
}
<div class="idle-card-note">@GapLabel()</div>
@if ( _rankClimb > 0 )
{
<div class="idle-card-note up">@ClimbLabel()</div>
}
}
</div>
</div>
@* ────────────────────── CENTER COLUMN ────────────────────── *@
<div class="idle-col idle-center">
<div class="idle-board-wrap">
<div class="idle-board @SurgeClass() @BoardShake()">
@for ( int r = 0; r < R.Size; r++ )
{
<div class="idle-row">
@for ( int c = 0; c < R.Size; c++ )
{
int row = r;
int col = c;
var cell = R.Board[row, col];
bool golden = R.GoldenActive && R.GoldenRow == row && R.GoldenCol == col;
<div class="idle-cell @CellClass( cell, golden )"
style="width:@(CellPx)px;height:@(CellPx)px;"
onclick=@(() => OnCellClick( row, col ))>
@if ( golden )
{
<div class="idle-golden">🌟</div>
<div class="idle-golden-timer">@GoldenTimerLabel()</div>
}
@if ( !golden && cell.HasChest )
{
<div class="idle-chest" style="@ChestStyle( cell )">@ChestGlyph( cell )</div>
}
@if ( !golden && cell.HasChest && cell.MaxHits > 1 )
{
<div class="idle-hits">@cell.HitsLeft</div>
}
@if ( cell.HasDrone )
{
<div class="idle-drone" style="@DroneStyle( cell )">💣</div>
}
</div>
}
</div>
}
</div>
@foreach ( var floater in _floaters )
{
var f = floater;
<div class="idle-float @f.Cls"
style="left:@f.X.ToString("F1")%;top:@f.Y.ToString("F1")%;opacity:@f.Alpha.ToString("F2");transform:translateY(@f.Rise.ToString("F0")px) scale(@f.Scale.ToString("F2"));">
@f.Text
</div>
}
@if ( _calloutLife > 0f )
{
<div class="idle-callout @_calloutCls" style="opacity:@_calloutLife.ToString("F2");">@_calloutText</div>
}
</div>
<div class="idle-controls">
<button class="idle-overload @OverloadClass()" onclick=@OnOverload>
<div class="idle-overload-fill" style="width:@OverloadFillPct()%;"></div>
<span class="idle-overload-label">@OverloadLabel()</span>
<span class="idle-overload-sub">@OverloadSubLabel()</span>
</button>
<div class="idle-surge-wrap">
<div class="idle-surge-track @SurgeReadyClass()">
<div class="idle-surge-fill" style="width:@Pct( R.Charge )%;"></div>
<span class="idle-surge-lbl">@SurgeLabel()</span>
</div>
@if ( R.SurgeReady )
{
<button class="idle-surge-btn" onclick=@OnSurge>@UnleashLabel()</button>
}
</div>
</div>
<div class="idle-meltdown-strip @MeltdownStripClass()">
<span class="idle-melt-icon">☢️</span>
<span class="idle-melt-text">@MeltdownLabel()</span>
@if ( R.CanMeltdown )
{
<button class="idle-melt-btn" onclick=@OnOpenMeltdown>REVIEW</button>
}
@if ( !R.CanMeltdown )
{
<div class="idle-melt-bar">
<div class="idle-melt-fill" style="width:@Pct( R.MeltdownProgress )%;"></div>
</div>
}
</div>
</div>
@* ─────────────────────── RIGHT COLUMN ────────────────────── *@
<div class="idle-col idle-right">
<div class="idle-tabs">
<button class="idle-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚙️</button>
<button class="idle-tab @TabClass("chains")" onclick=@(() => SetTab("chains"))>⛓️</button>
<button class="idle-tab @TabClass("systems")" onclick=@(() => SetTab("systems"))>🛠️</button>
<button class="idle-tab fluxtab @TabClass("flux")" onclick=@(() => SetTab("flux"))>🔷</button>
<button class="idle-tab @TabClass("log")" onclick=@(() => SetTab("log"))>📜</button>
</div>
@if ( IsUpgradeTab )
{
<div class="idle-buyrow">
<span class="idle-buyrow-lbl">BUY</span>
@foreach ( var amount in BuyAmounts )
{
int amt = amount;
<button class="idle-buy-amt @BuyAmtClass( amt )" onclick=@(() => SetBuyAmount( amt ))>@BuyAmountLabel( amt )</button>
}
</div>
}
<div class="idle-panel">
@if ( IsUpgradeTab )
{
@foreach ( var upgrade in IdleUpgradeCatalog.InCategory( CurrentCategory ) )
{
var up = upgrade;
bool revealed = R.IsRevealed( up );
int lvl = S.Level( up.Id );
bool maxed = lvl >= up.MaxLevel;
int count = revealed ? BuyCount( up.Id ) : 0;
double cost = (!revealed || maxed) ? 0 : R.CostFor( up.Id, count );
bool afford = revealed && !maxed && count > 0 && Cores >= cost;
<div class="idle-up @UpgradeClass( revealed, maxed, afford )" onclick=@(() => OnBuy( up.Id ))>
<div class="idle-up-icon">@UpgradeIcon( up, revealed )</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@UpgradeName( up, revealed )</span>
<span class="idle-up-lvl">@LevelLabel( up, lvl, revealed )</span>
</div>
<div class="idle-up-desc">@UpgradeDesc( up, revealed )</div>
@if ( revealed )
{
<div class="idle-up-bottom">
<span class="idle-up-effect">@up.Effect( lvl )</span>
<span class="idle-up-cost @MaxedClass( maxed )">@CostLabel( maxed, cost, count )</span>
</div>
}
</div>
</div>
}
}
@if ( _tab == "flux" )
{
<div class="idle-flux-head">
<div class="idle-flux-amount">@FluxHeadLabel()</div>
<div class="idle-flux-sub">@FluxSubLabel()</div>
</div>
@foreach ( var perk in IdleUpgradeCatalog.Perks )
{
var p = perk;
int lvl = S.PerkLevel( p.Id );
bool maxed = lvl >= p.MaxLevel;
int cost = p.CostAt( lvl );
bool afford = !maxed && S.Flux >= cost;
<div class="idle-up perk @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPerk( p.Id ))>
<div class="idle-up-icon">@p.Icon</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@p.Name</span>
<span class="idle-up-lvl">@PerkLevelLabel( p, lvl )</span>
</div>
<div class="idle-up-desc">@p.Desc</div>
<div class="idle-up-bottom">
<span class="idle-up-effect">@p.Effect( lvl )</span>
<span class="idle-up-cost flux @MaxedClass( maxed )">@PerkCostLabel( maxed, cost )</span>
</div>
</div>
</div>
}
}
@if ( _tab == "log" )
{
<div class="idle-flux-head">
<div class="idle-flux-amount">@LogHeadLabel()</div>
<div class="idle-flux-sub">Each one pays out once.</div>
</div>
@foreach ( var achievement in IdleAchievements.All )
{
var a = achievement;
bool done = S.HasAchievement( a.Id );
<div class="idle-up ach @(done ? "done" : "")">
<div class="idle-up-icon">@AchIcon( a, done )</div>
<div class="idle-up-body">
<div class="idle-up-top">
<span class="idle-up-name">@a.Name</span>
<span class="idle-ach-reward">@RewardLabel( a )</span>
</div>
<div class="idle-up-desc">@a.Desc</div>
@if ( !done )
{
<div class="idle-ach-bar">
<div class="idle-ach-fill" style="width:@Pct( a.Fraction( S ) )%;"></div>
</div>
<div class="idle-ach-prog">@ProgressLabel( a )</div>
}
</div>
</div>
}
}
</div>
</div>
</div>
}
@* ═════════════════════════ TOASTS ═════════════════════════ *@
<div class="idle-toasts">
@foreach ( var toast in _toasts )
{
var t = toast;
<div class="idle-toast @t.Cls" style="opacity:@t.Alpha.ToString("F2");">
<span class="idle-toast-icon">@t.Icon</span>
<span class="idle-toast-text">@t.Text</span>
</div>
}
</div>
@* ═══════════════════ WELCOME BACK OVERLAY ═══════════════════ *@
@if ( R.OfflinePending )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal offline">
<div class="idle-modal-eyebrow">REACTOR REPORT</div>
<div class="idle-modal-title">Welcome back</div>
<div class="idle-modal-sub">@OfflineSubLabel()</div>
<div class="idle-offline-haul">
<div class="idle-offline-amount">@OfflineAmountLabel()</div>
<div class="idle-offline-lbl">CORES</div>
</div>
@if ( R.PendingOfflineFrags > 0 )
{
<div class="idle-offline-extra">@OfflineFragLabel()</div>
}
@if ( OfflineTankFull )
{
<div class="idle-offline-warn">@OfflineWarnLabel()</div>
}
<button class="idle-modal-btn" onclick=@OnClaimOffline>COLLECT</button>
</div>
}
@* ═══════════════════ DAILY STREAK OVERLAY ═══════════════════ *@
@if ( !R.OfflinePending && !_showIntro && R.DailyPending )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal daily">
<div class="idle-modal-eyebrow">DAILY CYCLE</div>
<div class="idle-modal-title">@DailyTitleLabel()</div>
<div class="idle-modal-sub">Come back tomorrow to keep the streak climbing. Day 7 pays eight times.</div>
<div class="idle-streak-row">
@for ( int d = 1; d <= 7; d++ )
{
int day = d;
<div class="idle-streak-pip @StreakPipClass( day )">@day</div>
}
</div>
<div class="idle-daily-reward">
<div class="idle-daily-mult">@DailyMultLabel()</div>
<div class="idle-daily-lbl">@DailyRewardLabel()</div>
</div>
<button class="idle-modal-btn" onclick=@OnClaimDaily>CLAIM</button>
</div>
}
@* ══════════════════════ MELTDOWN MODAL ══════════════════════ *@
@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && _showMeltdown )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal meltdown">
<div class="idle-modal-eyebrow">PRESTIGE</div>
<div class="idle-modal-title">MELTDOWN</div>
<div class="idle-modal-sub">Collapse this cycle into permanent Flux. Every Flux you have ever earned adds income forever, and Flux buys perks that survive every reset.</div>
<div class="idle-melt-payout">
<div class="idle-melt-payout-val">@MeltPayoutLabel()</div>
<div class="idle-melt-payout-lbl">FLUX</div>
</div>
<div class="idle-melt-cols">
<div class="idle-melt-col keep">
<div class="idle-melt-col-hd">KEPT</div>
<div class="idle-melt-line">Flux and every perk</div>
<div class="idle-melt-line">Reactor Log entries</div>
<div class="idle-melt-line">Records and Fragments</div>
<div class="idle-melt-line">Kickstart head start</div>
</div>
<div class="idle-melt-col lose">
<div class="idle-melt-col-hd">BURNED</div>
<div class="idle-melt-line">@BurnedCoresLabel()</div>
<div class="idle-melt-line">All Core upgrades</div>
<div class="idle-melt-line">Reactor Frame size</div>
<div class="idle-melt-line">Unlocked chest tiers</div>
</div>
</div>
<div class="idle-modal-btns">
<button class="idle-modal-btn ghost" onclick=@OnCloseMeltdown>Not yet</button>
<button class="idle-modal-btn danger" onclick=@OnMeltdown>MELT DOWN</button>
</div>
</div>
}
@* ════════════════════════ INTRO MODAL ═══════════════════════ *@
@if ( !R.OfflinePending && _showIntro )
{
<div class="idle-modal-bg"></div>
<div class="idle-modal intro">
<div class="idle-modal-eyebrow">HOW IT WORKS</div>
<div class="idle-modal-title">THE REACTOR</div>
<div class="idle-modal-sub">A board that plays itself. Chests drop, drones bomb them, chain reactions pay Cores. You never lose here — you only grow.</div>
<div class="idle-intro-steps">
<div class="idle-intro-step">
<span class="idle-intro-num">1</span>
<span class="idle-intro-txt">Spend Cores on the right. Longer chains are worth far more than richer chests.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">2</span>
<span class="idle-intro-txt">Hit OVERLOAD, or press SPACE, for a burst. It charges the Surge meter — ten times output when you unleash it.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">3</span>
<span class="idle-intro-txt">It keeps running while you are on the menu or another screen. Close the game and it stops.</span>
</div>
<div class="idle-intro-step">
<span class="idle-intro-num">4</span>
<span class="idle-intro-txt">Cores condense into Fragments, which feed your companion for a permanent output bonus. Meltdown turns a whole cycle into Flux.</span>
</div>
</div>
<button class="idle-modal-btn" onclick=@OnCloseIntro>START THE REACTOR</button>
</div>
}
</div>
}
</root>
@code {
public static IdleHud Instance { get; private set; }
IdleReactor R => IdleReactor.Instance;
ChainReactionGame Game => ChainReactionGame.Instance;
IdleSave S => ChainReactionGame.Save?.Idle;
bool Visible => Game?.GameStarted == true
&& Game?.Mode == GameMode.Idle
&& R != null && S != null;
double Cores => S?.Cores ?? 0;
bool ModalOpen => R.OfflinePending || R.DailyPending || _showMeltdown || _showIntro;
bool ShowBoosts => R.Boosts.Count > 0 || R.SurgeActive;
bool OfflineTankFull => R.PendingOfflineSeconds > R.OfflineCapHours * 3600f;
// ── UI state ──────────────────────────────────────────────────
string _tab = "reactor";
int _buyAmount = 1; // -1 == MAX
bool _showMeltdown;
bool _showIntro;
bool _subscribed;
bool _wasVisible;
static readonly int[] BuyAmounts = { 1, 10, 25, -1 };
bool IsUpgradeTab => _tab == "reactor" || _tab == "chains" || _tab == "systems";
IdleUpgradeCategory CurrentCategory => _tab switch
{
"reactor" => IdleUpgradeCategory.Reactor,
"chains" => IdleUpgradeCategory.Chains,
_ => IdleUpgradeCategory.Systems,
};
// ── Floating numbers ──────────────────────────────────────────
class Floater
{
public string Text;
public string Cls;
public float X, Y;
public float Life, MaxLife;
public float Alpha => MathF.Min( 1f, Life / 0.35f );
public float Rise => -(MaxLife - Life) * 42f;
public float Scale => 0.85f + MathF.Min( 0.35f, (MaxLife - Life) * 1.2f );
}
readonly List<Floater> _floaters = new();
class ToastMsg
{
public string Icon;
public string Text;
public string Cls;
public float Life;
public float Alpha => MathF.Min( 1f, Life / 0.4f );
}
readonly List<ToastMsg> _toasts = new();
string _calloutText = "";
string _calloutCls = "";
float _calloutLife;
// Board shake is kept local rather than going through ScreenShaker so the
// class string stays stable between rebuilds — a changing class would
// restart the animation on every repaint and read as a vibration.
string _shakeCls = "";
float _shakeUntil;
int CellPx => R == null ? 92 : R.Size switch
{
5 => 96,
6 => 84,
7 => 74,
8 => 66,
_ => 59,
};
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
// Reactor rank card
//
// Deliberately NOT a top-N board. A top-N motivates whoever is already in
// it and is scenery for everyone else; the crossable goal is the gap to the
// player directly above, so the card shows a window around the local player
// (two up, you, one down).
//
// The board snapshot is stale between refreshes but S.FluxEarned is live,
// so the local row and the gap are always computed from the local value.
// That makes the gap tick down while the player watches, which is the whole
// point of the card.
// ══════════════════════════════════════════════════════════════
class RankRow
{
public string RankLabel;
public string Name;
public string ValueLabel;
public bool IsMe;
}
readonly List<RankRow> _rankRows = new();
Sandbox.Services.Leaderboards.Board2 _rankBoard;
bool _rankLoading;
bool _rankFetched;
float _rankNextFetch;
int _myRank; // 1-based, 0 == not on the board
long _nextUpValue; // stat value of the player directly above, 0 == none above
int _rankClimb; // places gained since the last session
int _rankTotalPlayers; // Board2.TotalEntries — real population, not window size
const float RankRefreshSeconds = 90f;
const string RankStat = "chain_reaction_reactor";
/// <summary>
/// Network call — only ever driven by the throttle in OnUpdate, never from
/// the render path. BuildHash changes many times a second, so fetching from
/// there would hammer the backend into rate-limiting us.
/// </summary>
async System.Threading.Tasks.Task LoadRank()
{
if ( _rankLoading ) return;
_rankLoading = true;
try
{
var b = Sandbox.Services.Leaderboards.GetFromStat( RankStat );
b.SetAggregationMax();
b.SetSortDescending();
// CenterOnMe is exactly what this card wants: Board2 returns the
// entries surrounding a target player, so asking for a small window
// centred on us gives the neighbours directly. No manual slicing.
b.CenterOnMe();
b.MaxEntries = 5;
await b.Refresh();
_rankBoard = b;
_rankTotalPlayers = (int)b.TotalEntries;
Log.Info( $"Rank '{RankStat}' — window: {b.Entries?.Length ?? -1}, board total: {b.TotalEntries}" );
}
catch ( Exception e )
{
Log.Warning( $"Reactor rank: {e.Message}" );
}
_rankLoading = false;
_rankFetched = true;
RebuildRankRows();
StateHasChanged();
}
void RebuildRankRows()
{
_rankRows.Clear();
_myRank = 0;
_nextUpValue = 0;
_rankClimb = 0;
var entries = _rankBoard?.Entries;
if ( entries == null || entries.Length == 0 ) return;
// CenterOnMe means these indices are window-relative, NOT board-relative,
// so ranks must come from Entry.Rank. Using index+1 here would label the
// window "#1..#5" no matter where the player actually sits.
long myId = Sandbox.Connection.Local?.SteamId ?? 0;
int meIdx = -1;
for ( int i = 0; i < entries.Length; i++ )
{
if ( entries[i].SteamId == myId ) { meIdx = i; break; }
}
if ( meIdx < 0 )
{
// No entry for us yet — show the window we were given so there is
// still a named target to chase rather than an empty card.
for ( int i = 0; i < entries.Length; i++ )
_rankRows.Add( MakeRankRow( entries[i], false ) );
_nextUpValue = (long)entries[entries.Length - 1].Value;
return;
}
_myRank = (int)entries[meIdx].Rank;
int start = Math.Max( 0, meIdx - 2 );
int end = Math.Min( entries.Length - 1, meIdx + 1 );
for ( int i = start; i <= end; i++ )
_rankRows.Add( MakeRankRow( entries[i], i == meIdx ) );
if ( meIdx > 0 ) _nextUpValue = (long)entries[meIdx - 1].Value;
// Places gained since we last recorded a rank. Persisting this is what
// turns offline production into "I moved up while I was away".
int prev = S?.LastReactorRank ?? 0;
if ( prev > 0 && _myRank > 0 && _myRank < prev ) _rankClimb = prev - _myRank;
if ( S != null && _myRank > 0 && S.LastReactorRank != _myRank )
{
S.LastReactorRank = _myRank;
ChainReactionGame.Save?.Save();
}
}
RankRow MakeRankRow( Sandbox.Services.Leaderboards.Board2.Entry e, bool isMe )
{
// The snapshot lags our own earnings, so never show the player a number
// lower than what they have actually banked.
long val = isMe
? Math.Max( (long)e.Value, S?.FluxEarned ?? 0 )
: (long)e.Value;
return new RankRow
{
RankLabel = "#" + e.Rank.ToString(),
Name = isMe ? "you" : RankName( e ),
ValueLabel = val.ToString( "N0" ),
IsMe = isMe,
};
}
string RankName( Sandbox.Services.Leaderboards.Board2.Entry e )
{
if ( !string.IsNullOrWhiteSpace( e.DisplayName ) ) return e.DisplayName;
try
{
var friend = new Sandbox.Friend( e.SteamId );
if ( !string.IsNullOrWhiteSpace( friend.Name ) ) return friend.Name;
}
catch { }
return "Anonymous";
}
string RankRowClass( RankRow r ) => r.IsMe ? "me" : "";
string RankEmptyLabel()
{
if ( !_rankFetched ) return "Reading the board";
return "Earn Flux to enter the rankings";
}
string GapLabel()
{
if ( _nextUpValue <= 0 ) return "Top of the board";
long mine = S?.FluxEarned ?? 0;
long gap = _nextUpValue - mine;
if ( gap <= 0 ) return "Ahead — board updates shortly";
return gap.ToString( "N0" ) + " Flux to pass";
}
string ClimbLabel()
{
if ( _rankClimb == 1 ) return "Up 1 place since last time";
return "Up " + _rankClimb.ToString() + " places since last time";
}
// Lifecycle
// ══════════════════════════════════════════════════════════════
protected override void OnStart()
{
Instance = this;
}
protected override void OnUpdate()
{
if ( !_subscribed && R != null ) Subscribe();
bool vis = Visible;
if ( vis != _wasVisible )
{
_wasVisible = vis;
if ( vis )
{
_floaters.Clear();
_toasts.Clear();
_calloutLife = 0f;
_shakeUntil = 0f;
_showMeltdown = false;
_showIntro = S != null && !S.IntroShown;
_tab = "reactor";
_rankNextFetch = 0f; // refetch on entering idle mode
}
StateHasChanged();
}
if ( !vis ) return;
// Wall-clock throttle. Never move this into BuildHash or the render path.
if ( RealTime.Now >= _rankNextFetch )
{
_rankNextFetch = RealTime.Now + RankRefreshSeconds;
_ = LoadRank();
}
// SPACE is the detonate key everywhere else in the game, so it fires the
// reactor's manual burst here too.
if ( Input.Pressed( "Jump" ) && R.OverloadReady && !ModalOpen )
R.Overload();
float dt = Time.Delta;
for ( int i = _floaters.Count - 1; i >= 0; i-- )
{
_floaters[i].Life -= dt;
if ( _floaters[i].Life <= 0f ) _floaters.RemoveAt( i );
}
for ( int i = _toasts.Count - 1; i >= 0; i-- )
{
_toasts[i].Life -= dt;
if ( _toasts[i].Life <= 0f ) _toasts.RemoveAt( i );
}
if ( _calloutLife > 0f ) _calloutLife -= dt * 1.4f;
}
void Subscribe()
{
var r = R;
if ( r == null || _subscribed ) return;
r.OnPop += OnPop;
r.OnToast += OnToastEvent;
r.OnDetonation += OnDetonation;
_subscribed = true;
}
/// <summary>
/// Rebuilds only when something on screen would actually differ.
///
/// The first version forced a repaint 15 times a second, which meant
/// rebuilding the board, both side columns and every upgrade row constantly
/// — that was the source of the stutter. Between detonations almost nothing
/// changes, so hashing real state instead of a clock cuts the repaint rate
/// by roughly an order of magnitude.
/// </summary>
protected override int BuildHash()
{
if ( !Visible ) return 0;
var hc = new HashCode();
hc.Add( _tab );
hc.Add( _buyAmount );
hc.Add( _showMeltdown );
hc.Add( _showIntro );
hc.Add( ModalOpen );
// Currencies — quantised to what the labels actually show
hc.Add( (long)Cores );
hc.Add( (long)R.Cps );
hc.Add( S.Flux );
hc.Add( S.FluxEarned );
hc.Add( _rankRows.Count );
hc.Add( _myRank );
hc.Add( _rankClimb );
hc.Add( ChainReactionGame.Save?.TotalFragments ?? 0 );
hc.Add( S.TodayReactorFragments );
hc.Add( (int)(R.FragmentProgressFraction * 60f) );
// Live controls
hc.Add( (int)(R.OverloadCooldownLeft * 10f) );
hc.Add( (int)(R.Charge * 100f) );
hc.Add( R.SurgeActive );
hc.Add( (int)R.SurgeTimeLeft );
hc.Add( (int)R.GoldenTimeLeft );
hc.Add( R.Boosts.Count );
hc.Add( R.Boosts.Count > 0 ? (int)R.Boosts[0].SecondsLeft : 0 );
hc.Add( R.FeedStarved );
// Transient effects
hc.Add( _floaters.Count );
hc.Add( (int)(_calloutLife * 20f) );
hc.Add( Time.Now < _shakeUntil ? _shakeCls : "" );
hc.Add( _toasts.Count );
// Board — chest layout, damage, drones and any live flash
hc.Add( R.Size );
float now = Time.Now;
bool animating = _floaters.Count > 0 || _calloutLife > 0f || now < _shakeUntil;
for ( int r = 0; r < R.Size; r++ )
for ( int c = 0; c < R.Size; c++ )
{
var cell = R.Board[r, c];
hc.Add( (int)cell.Chest );
hc.Add( cell.HitsLeft );
hc.Add( cell.HasDrone );
hc.Add( cell.FlashUntil > now ? cell.FlashKind : 0 );
if ( cell.FlashUntil > now ) animating = true;
if ( cell.HasChest && now - cell.SpawnAt < 0.25f ) animating = true;
if ( cell.HasDrone && now - cell.DroneAt < 0.18f ) animating = true;
}
hc.Add( R.GoldenRow );
hc.Add( R.GoldenCol );
hc.Add( R.LastChain );
// Anything mid-flight is drawn from inline styles rather than keyframes,
// so it needs a clock — but only while it is actually moving. The rest of
// the time this hash is stable and the panel is never rebuilt at all.
if ( animating ) hc.Add( (int)(now * 20f) );
return hc.ToHashCode();
}
// ══════════════════════════════════════════════════════════════
// Reactor events
// ══════════════════════════════════════════════════════════════
void OnPop( int row, int col, double value, bool crit )
{
if ( R == null || _floaters.Count > 10 ) return;
float step = 100f / R.Size;
_floaters.Add( new Floater {
Text = "+" + IdleFormat.Short( value ),
Cls = crit ? "crit" : "",
X = step * col + step * 0.5f,
Y = step * row + step * 0.5f,
Life = 0.9f,
MaxLife = 0.9f,
} );
}
void OnToastEvent( string icon, string text, string cls )
{
_toasts.Insert( 0, new ToastMsg { Icon = icon, Text = text, Cls = cls, Life = 3.2f } );
while ( _toasts.Count > 4 ) _toasts.RemoveAt( _toasts.Count - 1 );
}
void OnDetonation( int chain, double payout, bool crit )
{
string shake = crit || chain >= 25 ? "idle-shake-3"
: chain >= 12 ? "idle-shake-2"
: chain >= 4 ? "idle-shake-1"
: "";
if ( shake != "" )
{
_shakeCls = shake;
_shakeUntil = Time.Now + (shake == "idle-shake-3" ? 0.45f : 0.28f);
}
if ( chain < 15 && !crit ) return;
double critPower = IdleBalance.CritPower( S?.Level( IdleUpgradeId.CritPower ) ?? 0 );
_calloutText = crit
? $"CRIT ×{critPower:F0} · +{IdleFormat.Short( payout )}"
: $"{chain} CHAIN · +{IdleFormat.Short( payout )}";
_calloutCls = crit ? "crit" : chain >= 35 ? "mega" : "big";
_calloutLife = 1f;
}
// ══════════════════════════════════════════════════════════════
// Input
// ══════════════════════════════════════════════════════════════
void SetTab( string tab ) { _tab = tab; Sound.Play( "UI_Button" ); }
void SetBuyAmount( int amount ) { _buyAmount = amount; Sound.Play( "UI_Button" ); }
int BuyCount( IdleUpgradeId id )
=> _buyAmount < 0 ? Math.Max( 1, R.MaxAffordable( id ) ) : _buyAmount;
void OnBuy( IdleUpgradeId id )
{
if ( R == null ) return;
if ( !R.TryBuy( id, BuyCount( id ) ) ) Sound.Play( "Pop_Button_1" );
}
void OnBuyPerk( IdlePerkId id )
{
if ( R == null ) return;
if ( !R.TryBuyPerk( id ) ) Sound.Play( "Pop_Button_1" );
}
void OnOverload() => R?.Overload();
void OnSurge() => R?.ActivateSurge();
void OnCellClick( int r, int c ) => R?.ClickCell( r, c );
void OnClaimOffline() => R?.ClaimOffline();
void OnClaimDaily() => R?.ClaimDaily();
void OnOpenMeltdown() { _showMeltdown = true; Sound.Play( "UI_Button" ); }
void OnCloseMeltdown() { _showMeltdown = false; Sound.Play( "UI_Button" ); }
void OnMeltdown()
{
_showMeltdown = false;
R?.DoMeltdown();
}
void OnCloseIntro()
{
_showIntro = false;
if ( S != null )
{
S.IntroShown = true;
ChainReactionGame.Save?.Save();
}
Sound.Play( "PlayButton" );
}
void OnBack()
{
Sound.Play( "UI_Button" );
Game?.ReturnToMenu();
}
// ══════════════════════════════════════════════════════════════
// Class helpers
// ══════════════════════════════════════════════════════════════
string Pct( float f ) => (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "F1" );
string GlowClass() => R.SurgeActive ? "surging" : "";
string SurgeClass() => R.SurgeActive ? "surging" : "";
string SurgeReadyClass() => R.SurgeReady ? "ready" : "";
string OverloadClass() => R.OverloadReady ? "ready" : "cooling";
string BoardShake() => Time.Now < _shakeUntil ? _shakeCls : "";
string TabClass( string t ) => _tab == t ? "on" : "";
string BuyAmtClass( int amt ) => _buyAmount == amt ? "on" : "";
string MaxedClass( bool maxed ) => maxed ? "maxed" : "";
string CapClass() => R.FragmentsToday >= R.DailyFragmentCap ? "capped" : "";
string FeedClass() => R.FeedStarved ? "starved" : "";
string MeltdownStripClass() => R.CanMeltdown ? "ready" : "";
string StreakPipClass( int day )
{
int streak = S?.DailyStreak ?? 1;
if ( day == streak ) return "on today";
return day < streak ? "on" : "";
}
// ══════════════════════════════════════════════════════════════
// Text helpers — one composed string per element (see note at top)
// ══════════════════════════════════════════════════════════════
string CoresLabel() => IdleFormat.Short( Cores );
string RateLabel() => IdleFormat.Short( R.Cps );
string FluxLabel() => (S?.Flux ?? 0).ToString();
string FragLabel() => (ChainReactionGame.Save?.TotalFragments ?? 0).ToString();
string GlobalMultLabel() => "×" + IdleFormat.Short( R.GlobalMultiplier );
string ExponentLabel() => R.ChainExponent.ToString( "F2" );
string LastChainLabel() => $"{R.LastChain} chests";
string BestChainLabel() => $"{S.BestChain} chests";
string BoardLabel() => $"{R.Size} × {R.Size}";
string FeedLabel() => R.FeedStarved ? "starving" : "keeping up";
string SurgeBoostLabel() => $"SURGE ×{IdleBalance.SurgeMultiplier:F0}";
string BoostLabel( IdleReactor.Boost b ) => $"{b.Label} ×{b.Mult:F1}";
string SecondsLabel( float s ) => $"{s:F0}s";
string CondenserLabel()
=> $"next at {IdleFormat.Short( R.FragmentCost )} Cores · price climbs with each one";
string FragTodayLabel() => $"{R.FragmentsToday} / {R.DailyFragmentCap}";
string TierBlurb( IdleBalance.ChestTier tier, bool unlocked )
=> unlocked ? tier.Blurb : $"unlocks at {IdleFormat.Short( tier.Requires )} cycle Cores";
string GoldenTimerLabel() => R.GoldenTimeLeft.ToString( "F0" );
string OverloadLabel()
=> R.OverloadReady ? "⚡ OVERLOAD" : $"{R.OverloadCooldownLeft:F1}s";
string OverloadSubLabel() => $"×{IdleBalance.OverloadMultiplier:F1} burst · SPACE";
string OverloadFillPct() => ((1f - R.OverloadCooldownFraction) * 100f).ToString( "F0" );
string UnleashLabel() => $"UNLEASH ×{IdleBalance.SurgeMultiplier:F0}";
string SurgeLabel()
{
if ( R.SurgeActive ) return $"SURGE {R.SurgeTimeLeft:F0}s";
if ( R.SurgeReady ) return "SURGE READY";
return $"CHARGE {R.Charge * 100f:F0}%";
}
string MeltdownLabel()
=> R.CanMeltdown
? $"Meltdown available — +{R.PendingFlux} Flux"
: $"Meltdown unlocks at {IdleFormat.Short( IdleBalance.MeltdownRequirement )} cycle Cores · {IdleFormat.Short( S.CycleCores )} banked";
string BuyAmountLabel( int amount ) => amount < 0 ? "MAX" : $"×{amount}";
string UpgradeClass( bool revealed, bool maxed, bool afford )
{
if ( !revealed ) return "locked";
if ( maxed ) return "maxed";
return afford ? "afford" : "poor";
}
string UpgradeIcon( IdleUpgrade up, bool revealed ) => revealed ? up.Icon : "🔒";
string UpgradeName( IdleUpgrade up, bool revealed ) => revealed ? up.Name : "Locked";
string UpgradeDesc( IdleUpgrade up, bool revealed )
=> revealed ? up.Desc : $"Reach {IdleFormat.Short( up.RevealAt )} lifetime Cores to reveal.";
string LevelLabel( IdleUpgrade up, int lvl, bool revealed )
{
if ( !revealed ) return "—";
return up.MaxLevel < 100 ? $"Lv {lvl} / {up.MaxLevel}" : $"Lv {lvl}";
}
string CostLabel( bool maxed, double cost, int count )
{
if ( maxed ) return "MAXED";
return count > 1
? $"{IdleFormat.Short( cost )} ×{count}"
: IdleFormat.Short( cost );
}
string PerkLevelLabel( IdlePerk p, int lvl ) => $"Lv {lvl} / {p.MaxLevel}";
string PerkCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"🔷 {cost}";
string FluxHeadLabel() => $"🔷 {S.Flux}";
string FluxSubLabel()
=> $"{S.FluxEarned} earned · ×{IdleBalance.FluxMultiplier( S.FluxEarned ):F2} permanent income";
string LogHeadLabel() => $"{IdleAchievements.CompletedCount( S )} / {IdleAchievements.All.Count}";
string AchIcon( IdleAchievement a, bool done ) => done ? a.Icon : "🔘";
string RewardLabel( IdleAchievement a )
{
if ( a.RewardFlux > 0 && a.RewardFrags > 0 ) return $"+{a.RewardFlux}🔷 +{a.RewardFrags}💠";
if ( a.RewardFlux > 0 ) return $"+{a.RewardFlux}🔷";
return $"+{a.RewardFrags}💠";
}
string ProgressLabel( IdleAchievement a )
{
double cur = a.Progress( S );
return a.BigNumbers
? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( a.Target )}"
: $"{(long)cur} / {(long)a.Target}";
}
// ── Modal labels ──────────────────────────────────────────────
string OfflineSubLabel()
=> $"The reactor ran for {IdleFormat.Duration( R.PendingOfflineSeconds )} at {IdleFormat.Pct( R.OfflineEfficiency )} efficiency.";
string OfflineAmountLabel() => "+" + IdleFormat.Short( R.PendingOfflineCores );
string OfflineFragLabel() => $"condensing {R.PendingOfflineFrags} Fragments";
string OfflineWarnLabel()
=> $"Storage filled up after {R.OfflineCapHours:F0}h. Upgrade Core Storage to bank longer trips.";
string DailyTitleLabel() => $"Day {S?.DailyStreak ?? 1}";
string DailyMultLabel() => $"×{IdleBalance.DailyReward( S?.DailyStreak ?? 1 ).mult:F1}";
string DailyRewardLabel()
{
var reward = IdleBalance.DailyReward( S?.DailyStreak ?? 1 );
return $"for {IdleFormat.Duration( reward.seconds )} · +{reward.frags} Fragments";
}
string MeltPayoutLabel() => "+" + R.PendingFlux;
string BurnedCoresLabel() => $"{IdleFormat.Short( Cores )} Cores";
// ══════════════════════════════════════════════════════════════
// Cell rendering
// ══════════════════════════════════════════════════════════════
string CellClass( IdleReactor.Cell cell, bool golden )
{
if ( golden ) return "golden";
string cls = cell.HasChest ? "filled" : "empty";
if ( cell.FlashUntil > Time.Now )
{
cls += cell.FlashKind switch
{
3 => " fx-drone",
2 => " fx-kill",
_ => " fx-blast",
};
}
if ( cell.HasDrone ) cls += " armed";
if ( cell.HasChest )
{
cls += cell.Chest switch
{
GridManager.ChestType.Gem => " c-gem",
GridManager.ChestType.BombChest => " c-bomb",
GridManager.ChestType.Locked => " c-locked",
GridManager.ChestType.Cursed => " c-cursed",
GridManager.ChestType.Void => " c-void",
GridManager.ChestType.Boss => " c-boss",
_ => " c-normal",
};
}
return cls;
}
/// <summary>
/// Spawn pop, computed here rather than as a keyframe: the panel can rebuild
/// at any moment and a class-driven animation would restart mid-flight.
/// </summary>
string ChestStyle( IdleReactor.Cell cell )
{
float age = Time.Now - cell.SpawnAt;
if ( age >= 0.25f || age < 0f ) return "";
float scale = 0.4f + 0.6f * (age / 0.25f);
return $"transform:scale({scale.ToString( "F2" )});";
}
/// <summary>Drone drop — falls onto the cell as the volley arms.</summary>
string DroneStyle( IdleReactor.Cell cell )
{
float age = Time.Now - cell.DroneAt;
if ( age >= 0.18f || age < 0f ) return "";
float t = age / 0.18f;
float scale = 2.0f - 1.0f * t;
return $"transform:scale({scale.ToString( "F2" )});opacity:{t.ToString( "F2" )};";
}
string ChestGlyph( IdleReactor.Cell cell ) => cell.Chest switch
{
GridManager.ChestType.BombChest => "💣",
GridManager.ChestType.Locked => "🔒",
GridManager.ChestType.Cursed => "💀",
GridManager.ChestType.Void => "🌑",
GridManager.ChestType.Boss => "👑",
GridManager.ChestType.Gem => "💎",
_ => "🎁",
};
}
@using System;
@using Sandbox;
@using Sandbox.Game.Menu
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
@if(_active)
{
@* ── Debug card ── *@
@if(_debugMode)
{
<div class="tut-debug-card">
<div class="tut-debug-title">MM TUT — Step @_step</div>
<div class="tut-debug-row">
<span>BL: @_debugBL.ToString("F1")%</span>
<button class="tut-debug-btn" onclick=@(() => { _debugBL -= 1f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugBL += 1f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>BY: @_debugBY.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugBY -= 10f; StateHasChanged(); })>▲</button>
<button class="tut-debug-btn" onclick=@(() => { _debugBY += 10f; StateHasChanged(); })>▼</button>
</div>
<div class="tut-debug-row">
<span>BR: @_debugBR.ToString("F1")%</span>
<button class="tut-debug-btn" onclick=@(() => { _debugBR -= 1f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugBR += 1f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>HX: @_debugHX.ToString("F1")%</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHX -= 1f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHX += 1f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>HY: @_debugHY.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHY -= 10f; StateHasChanged(); })>▲</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHY += 10f; StateHasChanged(); })>▼</button>
</div>
<div class="tut-debug-row">
<span>HW: @_debugHW.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHW -= 10f; StateHasChanged(); })>◀</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHW += 10f; StateHasChanged(); })>▶</button>
</div>
<div class="tut-debug-row">
<span>HH: @_debugHH.ToString("F0")px</span>
<button class="tut-debug-btn" onclick=@(() => { _debugHH -= 10f; StateHasChanged(); })>▲</button>
<button class="tut-debug-btn" onclick=@(() => { _debugHH += 10f; StateHasChanged(); })>▼</button>
</div>
<button class="tut-debug-log" onclick=@LogCurrentValues>📋 Log</button>
</div>
}
@* ── Dim layer ── *@
<div class="tut-dim"></div>
@* ── Highlight ── *@
@if(_step < _steps.Length && _steps[_step].HW > 0)
{
var s = _steps[_step];
float hx = _debugHX != 0f ? _debugHX : s.HX;
float hy = _debugHY != 0f ? _debugHY : s.HY;
float hw = _debugHW != 0f ? _debugHW : s.HW;
float hh = _debugHH != 0f ? _debugHH : s.HH;
<div class="tut-highlight"
style="left:@hx.ToString("F1")%;top:@hy.ToString("F0")px;width:@hw.ToString("F0")px;height:@hh.ToString("F0")px;">
</div>
}
@* ── Instruction box ── *@
@if(_step < _steps.Length)
{
var s = _steps[_step];
float bl = _debugBL != 0f ? _debugBL : s.BL;
float br = _debugBR != 0f ? _debugBR : s.BR;
float by = _debugBY != 0f ? _debugBY : s.BY;
float boxWidthPct = s.BW > 0f ? (s.BW / 1920f * 100f) : (380f / 1920f * 100f);
string posStyle = br != 0f
? $"left:{(100f - br - boxWidthPct).ToString("F1")}%;"
: $"left:{bl.ToString("F1")}%;";
string boxStyle = $"{posStyle}top:{by.ToString("F0")}px;{(s.BW > 0f ? $"width:{s.BW.ToString("F0")}px;" : "")}";
<div class="tut-box" style="@boxStyle">
<div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
<div class="tut-title">@s.Title</div>
<div class="tut-text">@s.Text</div>
@* Action hint — shown when player must act *@
@if(s.Trigger == MMTutTrigger.HeartPurchased)
{
<div class="tut-action-hint">👆 @s.ActionHint</div>
}
@* Next button — shown on Any steps *@
@if(s.Trigger == MMTutTrigger.Any)
{
<button class="tut-btn-next" onclick=@NextStep>Got it →</button>
}
@* Final step — Play button *@
@if(s.Trigger == MMTutTrigger.Done)
{
<button class="tut-btn-next" onclick=@OnPlayAndComplete>▶ Let's play!</button>
}
</div>
}
}
</root>
@code {
public static MainMenuTutorial Instance { get; private set; }
public bool IsActive => _active;
bool _active = false;
public int Step => _step;
int _step = 0;
// ── Debug mode ──────────────────────────────────────────────────── //
const bool _debugMode = false; // set false before release
float _debugBL = 0f;
float _debugBR = 0f;
float _debugBY = 0f;
float _debugHX = 0f;
float _debugHY = 0f;
float _debugHW = 0f;
float _debugHH = 0f;
void LogCurrentValues()
{
Log.Info($"// MM Step {_step} — {_steps[_step].Title}");
Log.Info($"BL={_debugBL:F1}f, BY={_debugBY:F0}f, BR={_debugBR:F1}f,");
Log.Info($"HX={_debugHX:F1}f, HY={_debugHY:F0}f, HW={_debugHW:F0}f, HH={_debugHH:F0}f");
}
// ── Trigger types ────────────────────────────────────────────────── //
public enum MMTutTrigger
{
Any, // advances via Got it button
HeartPurchased, // advances when player buys Heart consumable
Done, // final step — shows Play button
}
// ── Step data ────────────────────────────────────────────────────── //
public struct MMTutStep
{
public string Title;
public string Text;
public string ActionHint;
public MMTutTrigger Trigger;
public float HX, HY, HW, HH; // highlight
public float BL, BR, BY, BW; // box position
}
// ── Steps ────────────────────────────────────────────────────────── //
// Positions are placeholder — tune with debug card in-game
static readonly MMTutStep[] _steps = new MMTutStep[]
{
// Step 1 — Welcome
new MMTutStep {
Title = "Welcome to Chain Reaction!",
Text = "Here's a quick tour of how the game works. It'll take 30 seconds.",
Trigger = MMTutTrigger.Any,
HW = 0,
BL = 47f, BY = 370f
},
// Step 2 — Play button
new MMTutStep {
Title = "Play runs to earn 💠 Fragments",
Text = "Fragments are the game's currency. The further you go each run, the more you earn.",
Trigger = MMTutTrigger.Any,
HW = 0,
BL = 47f, BY = 370f
},
// Step 3 — Shop → Modifiers
new MMTutStep {
Title = "The Shop — Modifiers 🎲",
Text = "Spend fragments to unlock Modifiers. These permanently strengthen every run you play. Unlock once, keep forever.",
Trigger = MMTutTrigger.Any,
HX = 40f, HY = 200f, HW = 110f, HH = 40f,
BR = 61f, BY = 120f
},
// Step 4 — Consumables explanation
new MMTutStep {
Title = "Consumables 🎁",
Text = "Consumables are single-use items you bring into a run. Buy them once, use them at any time during a run. A bar will appear in-game when you own one.",
Trigger = MMTutTrigger.Any,
HX = 46f, HY = 200f, HW = 130f, HH = 40f,
BR = 61f, BY = 110
},
// Step 5 — Buy Heart (action step)
new MMTutStep {
Title = "Get yourself a Heart ❤️",
Text = "We've added 100 💠 to your balance. Buy the Heart consumable — it gives you an extra life mid-run when you need it most.",
ActionHint = "Click the buy button on the Heart card",
Trigger = MMTutTrigger.HeartPurchased,
HX = 65f, HY = 860f, HW = 80f, HH = 70f,
BR = 7f, BY = 800f
},
// Step 6 — Pet tab
new MMTutStep {
Title = "Your secret package 📦",
Text = "Play 3 runs to unlock what's inside. This section is for cosmetic skins — coming soon.",
Trigger = MMTutTrigger.Any,
HX = 53f, HY = 360f, HW = 70f, HH = 40f,
BR = 50f, BY = 340f
},
// Step 7 — Bombs
new MMTutStep {
Title = "Unlock new Bombs 💣",
Text = "Different bomb types open up new chain reaction strategies. Mix and match to find your playstyle.",
Trigger = MMTutTrigger.Any,
HX = 57f, HY = 220f, HW = 90f, HH = 40f,
BR = 5f, BY = 130f
},
// Step 8 — Leaderboard
new MMTutStep {
Title = "Compete Globally 🏅",
Text = "The leaderboard tracks your best score, chain reactions, and pet level. How far can you go?",
Trigger = MMTutTrigger.Any,
HX = 75f, HY = 400f, HW = 10f, HH = 10f,
BR = 5f, BY = 160f
},
// Step 9 — Done
new MMTutStep {
Title = "You're ready. 💥",
Text = "Play runs → earn fragments → unlock things → become stronger. Good luck!",
Trigger = MMTutTrigger.Done,
BR = 20f, BY = 370f
},
};
// ── Lifecycle ────────────────────────────────────────────────────── //
protected override void OnStart()
{
Instance = this;
_active = false;
}
public void Activate(PlayerSave save)
{
//var save = ChainReactionGame.Save ?? PlayerSave.Load();
if (save?.MenuTutorialCompleted == true) return;
_step = save?.MenuTutorialStep ?? 0;
_active = true;
ResetDebug();
// If resuming at step 4 (Buy Heart) but heart already purchased, skip it
if (_step == 4 && (save?.GetConsumableCount(ConsumableId.Heart) ?? 0) > 0)
_step = 5;
// Step 3 requires shop/mods to be visible — navigate there
ApplySideEffect(_step);
StateHasChanged();
}
// ── Called from MainMenu when Heart is bought ────────────────────── //
public void OnHeartPurchased()
{
if (!_active || _step >= _steps.Length) return;
if (_steps[_step].Trigger != MMTutTrigger.HeartPurchased) return;
NextStep();
}
// ── Step advancement ─────────────────────────────────────────────── //
void NextStep()
{
_step++;
ResetDebug();
if (_step >= _steps.Length)
{
Complete();
return;
}
// Persist current step so we can resume after crash - save tuto step
var save = PlayerSave.Load();
if (save != null)
{
save.MenuTutorialStep = _step;
save.Save();
}
ApplySideEffect(_step);
Sound.Play("UI_Button");
StateHasChanged();
}
// ── Side effects — navigate tabs automatically ───────────────────── //
void ApplySideEffect(int step)
{
var mm = MainMenu.Instance;
if (mm == null) return;
switch(step)
{
case 2: // Show shop modifiers
mm.SetTabPublic("shop");
mm.SetShopTabPublic("mods");
// Grant frags on step before heart purchase
break;
case 3: // Show consumables
mm.SetTabPublic("shop");
mm.SetShopTabPublic("consumables");
break;
case 4: // Buy Heart — shop consumables + grant frags if not already granted
var save = ChainReactionGame.Save ?? PlayerSave.Load();
if (save != null && !save.TutorialHeartFragsGranted)
{
save.TotalFragments += 100;
save.TutorialHeartFragsGranted = true;
save.Save();
}
mm.SetTabPublic("shop");
mm.SetShopTabPublic("consumables");
mm.ReloadSave();
break;
case 5: // Pet tab
mm.SetTabPublic("shop");
mm.SetShopTabPublic("pets");
break;
case 6: // Bombs
mm.SetTabPublic("shop");
mm.SetShopTabPublic("bombs");
break;
case 7: // Leaderboard
mm.SetTabPublic("lb");
break;
case 8: // Done — no navigation needed
break;
}
}
void Skip() => Complete();
void OnPlayAndComplete()
{
Complete();
MainMenu.Instance?.StartGamePublic();
}
void Complete()
{
_active = false;
// Get save from MainMenu directly
var mm = MainMenu.Instance;
// just use ChainReactionGame.Save or load fresh
var save = PlayerSave.Load();
if (save != null)
{
save.MenuTutorialCompleted = true;
save.MenuTutorialStep = 0;
save.Save();
}
StateHasChanged();
}
void ResetDebug()
{
_debugBL = 0f; _debugBR = 0f; _debugBY = 0f;
_debugHX = 0f; _debugHY = 0f; _debugHW = 0f; _debugHH = 0f;
}
public void ForceReset()
{
_active = false;
_step = 0;
ResetDebug();
StateHasChanged();
}
protected override int BuildHash() =>
System.HashCode.Combine(_active, _step, _debugBL, _debugBY, _debugHX, _debugHY, _debugHW, _debugHH);
}@namespace KOTH.UI
@using KOTH.Notification
@using Sandbox.Events
@inherits PanelComponent
@if (!DeathCameraData.IsValid())
{
return;
}
<root>
<div class="dead">
killed by:
</div>
<div class="name">
@DeathCameraData.KillerName
</div>
<div class="hp">
@DeathCameraData.KillerHealth HP
</div>
</root>
@code
{
public static PlayerDeathHUD Instance { get; set; }
public PlayerDeathHUD()
{
Instance = this;
}
public FDeathCameraData DeathCameraData { get; set; }
public void SetData(FDeathCameraData Data)
{
DeathCameraData = Data;
}
protected override void OnEnabled()
{
base.OnEnabled();
}
protected override void OnUpdate()
{
base.OnUpdate();
}
protected override int BuildHash()
{
return HashCode.Combine(Time.Now);
}
}@* @namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]
@if (!Player.IsValid() || !EngiePlayer.IsValid() || EngiePlayer.ActiveTurretComponent == null)
{
return;
}
<root>
<label class="building">@(EngiePlayer.ActiveTurretComponent.DamageComponent.Health)/@(EngiePlayer.ActiveTurretComponent.DamageComponent.MaxBaseHealth)</label>
</root>
@code
{
public PlayerPawn Player => PlayerState.Local.PlayerPawn;
public EngiePlayer EngiePlayer => Player.GameObject.GetComponent<EngiePlayer>();
protected override int BuildHash()
{
if (!Player.IsValid())
return 0;
return HashCode.Combine(Time.Now);
}
}
*@@namespace KOTH.UI
@attribute [StyleSheet]
@inherits Panel
<root class="">
@* @foreach (var voice in VoiceList.Where(x => CanDisplay(x)))
{
<div class="row">
<div class="item-row with-background">
@{
var player = GetPlayer(voice);
}
<Icon class="square rounded shrink-0" File="/ui/chat/radio.png" size="24" style="transform: scale( @GetAmplitudeScale( voice ) )" />
<img class="avatar" src="avatar:@player.SteamId" />
<PlayerPanel PlayerState=@player />
</div>
</div>
} *@
</root>
@code
{
public IEnumerable<PlayerVoiceComponent> VoiceList => Scene.GetAllComponents<PlayerVoiceComponent>().Where(x => x.PlayerState.IsConnected);
private PlayerState GetPlayer(Voice voice)
{
return voice.Components.Get<PlayerState>();
}
private bool CanDisplay(Voice voice)
{
return voice.LastPlayed < 0.25f;
}
private string GetAmplitudeScale(Voice voice)
{
var scale = 0.5f + (voice.Amplitude * 2.365f);
return (1f + scale).ToString("0.#");
}
protected override int BuildHash()
{
return HashCode.Combine(Time.Now);
}
}@namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]
@if ( !Player.IsValid() )
return;
<root>
<div class="circle"></div>
</root>
@code
{
/// The player
public PlayerPawn Player => PlayerState.Local.PlayerPawn;
protected override int BuildHash()
{
if ( !Player.IsValid() || !Player.CurrentEquipment.IsValid() )
return 0;
return HashCode.Combine(Player, Player.CurrentEquipment);
}
}
@inherits PanelComponent
<root> <div class="ynal2p"> @t </div> </root>
@code
{
[Property] public game g {get;set;}
string t = "You need at least 2 players.";
protected override void OnUpdate()
{
if (g.IsActive && g.Players.Count >= 2)
d(this);
}
[Rpc.Broadcast]
public void d(PanelComponent pc)
{pc.Destroy();}
}@using Skateboard.Player
@using Skateboard.Tricks
@inherits PanelComponent
<root>
<div class="@GetMultiplierClass()">@_multiplierText</div>
<div class="@GetTrickClass()">@_trickText</div>
</root>
@code
{
private float _displayTime;
private const float MaxDisplayTime = 5f;
private bool _lastFinished;
private int _pulseTick;
private string _multiplierText = "";
private string _trickText = "";
private bool _done;
private bool _failed;
private bool _fadeout;
private bool _visuallyEmpty;
protected override void OnStart()
{
TrickScoreHolder.OnLocalTrickScoreUpdate += OnTrickScoreUpdate;
}
protected override void OnDestroy()
{
TrickScoreHolder.OnLocalTrickScoreUpdate -= OnTrickScoreUpdate;
}
private void OnTrickScoreUpdate()
{
_displayTime = 0f;
_pulseTick++;
StateHasChanged();
}
protected override void OnUpdate()
{
var holder = SkatePawn.Local?.TrickScores;
if ( holder is null )
return;
if ( _displayTime < MaxDisplayTime && holder.Finished )
_displayTime += Time.Delta;
if ( _lastFinished != holder.Finished )
{
_lastFinished = holder.Finished;
_displayTime = 0f;
}
var visuallyEmpty = holder.VisuallyEmpty;
var multiplierText = visuallyEmpty ? "" : $"{holder.Score} x {holder.Multiplier}";
var trickText = visuallyEmpty ? "" : holder.String;
var done = holder.Finished;
var failed = holder.Failed;
var fadeout = _displayTime >= MaxDisplayTime;
if ( _visuallyEmpty == visuallyEmpty &&
_multiplierText == multiplierText &&
_trickText == trickText &&
_done == done &&
_failed == failed &&
_fadeout == fadeout )
return;
_visuallyEmpty = visuallyEmpty;
_multiplierText = multiplierText;
_trickText = trickText;
_done = done;
_failed = failed;
_fadeout = fadeout;
StateHasChanged();
}
private string GetMultiplierClass()
{
return BuildClass( $"multiplier skate-trick-text {GetPulseClass()}" );
}
private string GetTrickClass()
{
return BuildClass( $"tricks skate-trick-text {GetPulseClass()}" );
}
private string BuildClass( string baseClass )
{
if ( _visuallyEmpty )
return $"{baseClass} hidden";
if ( _fadeout )
baseClass += " fadeout";
if ( _failed )
baseClass += " failed";
else if ( _done )
baseClass += " done";
return baseClass;
}
private string GetPulseClass()
{
return _pulseTick % 2 == 0 ? "pulse pulse-a" : "pulse pulse-b";
}
}
@inherits PanelComponent
<root> <div class="eyelids" style="opacity: @o;"> </div> </root>
@code
{
public float o {get;set;} = 0;
protected override int BuildHash() => System.HashCode.Combine(o);
}@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PerkChoicePanel.razor.scss")]
@{
var player = GetViewedPlayer();
var isReadOnly = IsReadOnly( player );
var showViewingLabel = isReadOnly && !Manager.Instance.IsSpectator;
if ( !player.IsValid() || (!player.IsChoosingLevelUpReward && !showViewingLabel) )
{
Manager.Instance.IsHoveringPerkChoicePanel = false;
return;
}
}
@{
var showBanish = player.HasGottenBanish;
var showSkip = GetStat( player, PlayerStat.SkipPerkNumRerolls ) > 0f && !player.IsBeingShownCurseChoices;
var showRandom = GetStat( player, PlayerStat.CanChooseRandomPerk ) > 0f;
int numChoices = player.GetDisplayedPerkChoiceCount();
}
<root style="bottom: @(Manager.Instance.ShowBossHealthbar ? 50 : 10)px;">
@if ( showViewingLabel )
{
<div class="viewing_label">
<label>VIEWING CHOICES OF</label>
<div class="viewing_avatar" style="background-image: url( avatar:@player.Network.Owner.SteamId );"></div>
</div>
}
@if ( player.IsChoosingLevelUpReward )
{
@if ( player.NumPerkPointsAvailable > (player.IsBeingShownCurseChoices ? 0 : 1) )
{
@{
var left = 33 + (showBanish ? -8 : 0) + (showSkip ? -6 : 0) + (showRandom ? -5 : 0);
}
<div class="num_left" style="left:@(left)%;">
@{ var extraPerks = player.NumPerkPointsAvailable - 1; }
<label style="color: #ffffffbb; padding-right: 2px;">@($"{extraPerks}")</label>
<label style="color: #ffffff88; padding-left: 2px;">@($"MORE {(extraPerks > 1 ? "PERKS" : "PERK")}")</label>
</div>
}
<div class="itemselection @(numChoices > 17 ? "scrollable" : "")" style="gap: @(numChoices == 5 ? 6 : 12)px;">
@{
bool onlyShowIcons = numChoices > 5;
}
@for ( int i = 0; i < player.GetDisplayedPerkChoiceCount(); i++ )
{
var perkType = player.GetDisplayedPerkChoice( i );
var isUnknown = player.GetDisplayedPerkChoiceUnknown( i );
var index = i;
<PerkChoice ViewedPlayer=@player PerkType=@perkType IsChoice=@true OnlyShowIcon=@onlyShowIcons Slot=@i IsUnknown=@isUnknown onclick="@(e => PerkChosen( e, perkType, index ))" />
}
@if ( GetStat( player, PlayerStat.ChooseTimeLimit ) > 0f )
{
<div class="timelimitbar">
@{
var timeLimitProgress = Utils.Map( player.RealTimeSinceOfferedChoices, 0f, GetStat( player, PlayerStat.ChooseTimeLimit ), 0f, 1f );
}
<div style="width:@((1f - timeLimitProgress) * 100f)%; background-color:@(Color.Lerp(new Color(1f, 0.8f, 0.8f), new Color(1f, 0f, 0f), timeLimitProgress).Rgba);"></div>
</div>
}
</div>
<div class="lower_row">
<div class="button_outer">
@{
bool canUseArmor = GetStat( player, PlayerStat.ArmorRerollCost ) > 0f && player.Armor >= (int)GetStat( player, PlayerStat.ArmorRerollCost );
bool isFree = GetStat( player, PlayerStat.FreeRerollTime ) > 0f && player.RealTimeSinceLvlUp < GetStat( player, PlayerStat.FreeRerollTime );
bool canReroll = player.NumRerollAvailable > 0 || isFree || canUseArmor;
var autoRerollTimerProgress = player.GetDisplayedAutoRerollTimerProgress();
}
<panel class="button_container @((canReroll && !isReadOnly) ? "" : "disabled")" style="background-image: url(@("/textures/ui/panel/reroll_panel.png"));" onclick="@(e => RerollClicked( e ))">
@{
string amountText = isFree ? "∞" : (canUseArmor ? $"{(int)GetStat(player, PlayerStat.ArmorRerollCost)}⛊" : $"{player.NumRerollAvailable}");
int amountFontSize = canUseArmor ? 16 : 20;
}
<InputHint class="ctrl" Button="R" style="opacity: @((canReroll && !isReadOnly) ? 1f : 0.08f);"></InputHint>
<label class="button" style="color:@((canReroll && !isReadOnly) ? "#E8FFF4CC" : "#ffffff99");"></label>
<label class="amount" style="color:@((canReroll && !isReadOnly) ? "#88B19E" : "#ffffff99"); font-size:@($"{amountFontSize}px");">@amountText</label>
@if ( isFree )
{
@{
var freeRerollProgress = Utils.Map( player.RealTimeSinceLvlUp, 0f, GetStat( player, PlayerStat.FreeRerollTime ), 0f, 1f );
}
<div class="free_reroll_bar">
<div style="width:@((1f - freeRerollProgress) * 100f)%;"></div>
</div>
}
@if ( autoRerollTimerProgress > 0f && canReroll )
{
<div class="autoreroll_bar">
<div style="width:@((1f - autoRerollTimerProgress) * 100f)%;"></div>
</div>
}
</panel>
</div>
@if ( showBanish )
{
<div class="button_outer">
<panel class="button_container @((player.NumBanishAvailable > 0 && !isReadOnly) ? "" : "disabled")" style="background-image: url(@("/textures/ui/panel/banish_panel.png"));" onclick="@(e => BanishClicked( e ))">
<InputHint class="ctrl" Button="banish" style="opacity: @((player.NumBanishAvailable > 0 && !isReadOnly) ? 1f : 0.08f);"></InputHint>
<label class="button" style="color:@((player.NumBanishAvailable > 0 && !isReadOnly) ? "#E8FFF4CC" : "#ffffff99");"></label>
<label class="amount" style="color:@((player.NumBanishAvailable > 0 && !isReadOnly) ? "#88B19E" : "#ffffff99");">@player.NumBanishAvailable</label>
</panel>
</div>
}
@if ( showSkip )
{
<div class="button_outer">
<panel class="button_container @(isReadOnly ? "disabled" : "")" style="background-image: url(@("/textures/ui/panel/skip_panel.png"));" onclick="@(e => SkipClicked( e ))">
<label class="button" style="color:@(isReadOnly ? "#ffffff99" : "#E8FFF4CC"); width: 90px;"></label>
<label class="amount" style="font-size: 16px; color:@(!isReadOnly && (int)GetStat(player, PlayerStat.SkipPerkNumRerolls) > 0 ? "#88B19E" : "#ffffff55");">@($"+{(int)GetStat(player, PlayerStat.SkipPerkNumRerolls)}")</label>
</panel>
</div>
}
@if ( showRandom )
{
<div class="button_outer">
<panel class="button_container @(isReadOnly ? "disabled" : "")" style="background-image: url(@("/textures/ui/panel/random_panel.png"));" onclick="@(e => RandomClicked( e ))">
<label class="button" style="color:@(isReadOnly ? "#ffffff99" : "#E8FFF4CC"); width: 120px;"></label>
</panel>
</div>
}
</div>
}
</root>
@code {
private Player GetViewedPlayer()
{
var manager = Manager.Instance;
var player = manager.SelectedPlayer.IsValid()
? manager.SelectedPlayer
: manager.LocalPlayer;
if ( player.IsValid() && player.IsDead )
return null;
return player;
}
private bool IsReadOnly( Player player )
{
return player.IsValid() && player != Manager.Instance.LocalPlayer;
}
private Player GetInteractivePlayer()
{
var player = GetViewedPlayer();
return IsReadOnly( player ) ? null : player;
}
private float GetStat( Player player, PlayerStat stat )
{
return player.IsValid() ? player.GetUiStat( stat ) : 0f;
}
protected override void OnMouseOver( MousePanelEvent e )
{
Manager.Instance.IsHoveringPerkChoicePanel = true;
}
protected override void OnMouseOut( MousePanelEvent e )
{
Manager.Instance.IsHoveringPerkChoicePanel = false;
}
protected void PerkChosen( PanelEvent e, TypeDescription type, int index )
{
e.StopPropagation();
if ( Manager.Instance.IsGameOver || Manager.Instance.IsPaused )
return;
var player = GetInteractivePlayer();
if ( !player.IsValid() )
return;
if ( Manager.Instance.HoveredPerkType != null && Manager.Instance.IsHoveredPerkAChoice )
{
Manager.Instance.HoveredPerkType = null;
Manager.Instance.HoveredPerkViewedPlayer = null;
Manager.Instance.HoveredPerkChoiceSlot = -1;
}
if ( !player.CanInteractWithChoices )
return;
if ( player.IsBanishMode )
{
player.CanInteractWithChoices = false;
player.BanishPerkUIChoice( type );
Manager.Instance.PlaySfxUI( "banish_perk", pitch: Utils.Map( player.NumBanishAvailable, 0, 5, 1f, 1.3f, EasingType.QuadIn ), volume: 0.95f );
}
else
{
player.CanInteractWithChoices = false;
player.AddPerkUIChoice( type );
Manager.Instance.PlaySfxUI( "click", pitch: Utils.Map( player.NumPerkPointsAvailable, 0, 10, 1f, 0.8f, EasingType.QuadIn ), volume: 0.75f );
}
}
protected void RerollClicked( PanelEvent e )
{
e.StopPropagation();
var player = GetInteractivePlayer();
if ( !player.IsValid() )
return;
player.UseReroll();
}
protected void BanishClicked( PanelEvent e )
{
e.StopPropagation();
var player = GetInteractivePlayer();
if ( !player.IsValid() )
return;
player.ToggleBanish();
}
protected void SkipClicked( PanelEvent e )
{
e.StopPropagation();
var player = GetInteractivePlayer();
if ( !player.IsValid() )
return;
player.RefreshAfterChoosingPerk();
var perkType = TypeLibrary.GetType( typeof( PerkSkipChoices ) );
if ( player.HasPerk( perkType ) )
{
var skipPerk = player.GetPerk( perkType ) as PerkSkipChoices;
if ( skipPerk != null )
skipPerk.OnSkip();
}
}
protected void RandomClicked( PanelEvent e )
{
e.StopPropagation();
var player = GetInteractivePlayer();
if ( !player.IsValid() )
return;
var perkType = TypeLibrary.GetType( typeof( PerkRandomChoice ) );
if ( player.HasPerk( perkType ) )
{
var randomPerk = player.GetPerk( perkType ) as PerkRandomChoice;
if ( randomPerk != null )
randomPerk.OnRandom();
}
}
protected override int BuildHash()
{
var player = GetViewedPlayer();
if ( player is null || !player.IsValid() )
return 0;
bool canUseArmor = GetStat( player, PlayerStat.ArmorRerollCost ) > 0f && player.Armor >= (int)GetStat( player, PlayerStat.ArmorRerollCost );
bool isFree = GetStat( player, PlayerStat.FreeRerollTime ) > 0f && player.RealTimeSinceLvlUp < GetStat( player, PlayerStat.FreeRerollTime );
var timeLimitHash = GetStat( player, PlayerStat.ChooseTimeLimit ) > 0f ? player.RealTimeSinceOfferedChoices.Relative : 0f;
var freeRerollHash = isFree ? player.RealTimeSinceLvlUp.Relative : 0f;
var autoRerollTimerHash = player.GetDisplayedAutoRerollTimerProgress();
var lockBanishHash = HashCode.Combine(
player.NumBanishAvailable,
player.HasGottenBanish
);
int buttonsHash = HashCode.Combine(
player.NumRerollAvailable,
canUseArmor,
player.Armor,
isFree,
(int)GetStat( player, PlayerStat.SkipPerkNumRerolls ),
(int)GetStat( player, PlayerStat.CanChooseRandomPerk )
);
var panelStateHash = HashCode.Combine(
player.IsChoosingLevelUpReward,
player.NumPerkPointsAvailable,
player.GetDisplayedPerkChoiceCount(),
player.PerkChoiceHash,
player.IsBeingShownCurseChoices,
IsReadOnly( player )
);
return HashCode.Combine(
timeLimitHash,
freeRerollHash,
lockBanishHash,
buttonsHash,
panelStateHash,
HashCode.Combine(
autoRerollTimerHash,
Manager.Instance.ShowBossHealthbar,
Input.UsingController
)
);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PerkIcon.razor.scss")]
@{
var player = Manager.Instance.LocalPlayer;
var rot = Angle + Perk.IconAngleOffset;
var scale = Perk.IconScale;
const float levelUpAnimDuration = 0.5f;
var levelUpScale = Perk.RealTimeSinceLevelUp < levelUpAnimDuration
? Utils.Map( Perk.RealTimeSinceLevelUp, 0f, levelUpAnimDuration, 1.75f, 1.0f, EasingType.QuadOut )
: 1.0f;
}
<root style="transform: rotate(@(rot)deg) scale(@(scale * levelUpScale));">
@{
// float opacity = (SS2Game.Current.IsGameOver || SS2Game.Current.SelectedPlayer != null) ? 4f : Utils.Map(Item.TimeSinceLevelUp, 0f, 3f, 4f, 1f);
float opacity = Utils.Map( Math.Min( Perk.RealTimeSinceLevelUp, Perk.RealTimeSinceLevelDown ), 0f, 3f, 4f, 1f);
<!-- todo: cache these -->
var attrib = TypeLibrary.GetType(Perk.GetType()).GetAttribute<PerkAttribute>();
var rarity = attrib.Rarity;
var curse = attrib.Curse;
var highlightColor = PerkManager.GetCardRarityColor(rarity, curse, alpha: 0.7f);
var rarityBgTint = PerkManager.GetCardRarityColor(rarity, curse);
var width = Math.Clamp(Perk.Level / (float)Perk.MaxLevel, 0f, 1f) * 100f;
var maxLevel = PerkManager.GetMaxLevelForRarity(rarity);
}
<panel class="rarity" style="opacity: @opacity; background-color: @highlightColor.Rgba; background-image-tint:@rarityBgTint.Rgba;"></panel>
@if (!curse && maxLevel > 1)
{
// @if (Perk.Level > (IsChoice ? 1 : 0))
// {
<panel class="progress" style="width:100%; background-color:@(new Color(0f, 0f, 0f, IsChoice ? 0.3f : 0.6f).Rgba); z-index: 1;"></panel>
// }
<panel class="progress" style="width:@(width)%; background-color:@highlightColor.Rgba; z-index: 2;"></panel>
}
<panel class="icon" style="background-image: url(@(Perk.GetImagePath( Perk.GetType() ))); background-color:@rarityBgTint.Rgba; opacity: @opacity;" />
@if (Perk.Level > Perk.MaxLevel)
{
<div class="maxed">X</div>
}
@if ( !IsChoice && Perk.DisplayCooldown > 0f) // && !Manager.Instance.IsGameOver
{
<div class="cooldown" style="width:@(Math.Clamp(Perk.DisplayCooldown, 0f, 1f) * 100f)%; opacity:@(Utils.Map(Parent.Opacity, 0.3f, 1f, 1.3f, 0.75f)); background-color:@(Perk.DisplayCooldownColor.Rgba);"></div>
}
@if ( !string.IsNullOrEmpty(Perk.DisplayText)) //&& !Manager.Instance.IsGameOver && SS2Game.Current.SelectedPlayer == null)
{
<label class="display_text" style="color:@(Perk.DisplayTextColor.Rgba); opacity:@Perk.DisplayTextOpacity">@Perk.DisplayText</label>
}
@if (Banished)
{
<div class="banish">X</div>
}
@if (Perk.RealTimeSinceHighlight < Perk.HighlightDuration)
{
<div class="highlight" style="opacity:@(Utils.Map( Perk.RealTimeSinceHighlight, 0f, Perk.HighlightDuration, Perk.HighlightOpacity, 0f, EasingType.SineOut)); background-color:@(Perk.HighlightColor.Rgba);"></div>
}
@if(Perk.RealTimeSinceLevelDown < 3f)
{
<div class="deleveled" style="opacity:@(Utils.Map( Perk.RealTimeSinceLevelDown, 0f, 3f, 4f, 0f, EasingType.QuadOut));"></div>
}
</root>
@code
{
public Perk Perk { get; set; }
public bool NoTips { get; set; } = false;
public bool Banished;
public float Angle { get; set; }
public float Scale { get; set; }
public bool IsChoice { get; set; }
protected override void OnMouseOver(MousePanelEvent e)
{
if (e.Target != this || NoTips || Perk == null)
return;
Manager.Instance.HoveredPerkType = TypeLibrary.GetType( Perk.GetType() );
Manager.Instance.HoveredPerkPanel = this;
Manager.Instance.HoveredPerkLevel = Perk.Level;
Manager.Instance.IsHoveredPerkBanished = Banished;
Manager.Instance.IsHoveredPerkAChoice = IsChoice;
Manager.Instance.IsHoveredPerkHidden = false;
Manager.Instance.HoveredPlayer = null;
Manager.Instance.HoveredPlayerIcon = null;
}
protected override void OnMouseOut(MousePanelEvent e)
{
if ( Manager.Instance.HoveredPerkPanel != this ) return;
Manager.Instance.HoveredPerkType = null;
Manager.Instance.HoveredPerkPanel = null;
Manager.Instance.IsHoveredPerkBanished = false;
}
protected override void OnAfterTreeRender ( bool firstTime )
{
base.OnAfterTreeRender( firstTime );
if ( !IsVisible && Manager.Instance.HoveredPerkPanel == this )
{
Manager.Instance.HoveredPerkType = null;
Manager.Instance.HoveredPerkPanel = null;
Manager.Instance.IsHoveredPerkBanished = false;
}
}
protected override int BuildHash()
{
var fadeHash = Math.Min(Perk.RealTimeSinceLevelUp, Perk.RealTimeSinceLevelDown) < 3f ? RealTime.Now : 0f;
var highlightHash = Perk.RealTimeSinceHighlight.Relative < Perk.HighlightDuration ? Perk.RealTimeSinceHighlight.Relative : 0f;
var cooldownHash = Perk.DisplayCooldown > 0f ? HashCode.Combine(Perk.DisplayCooldown, Parent.Opacity, Perk.DisplayCooldownColor) : 0f;
var textHash = !string.IsNullOrEmpty(Perk.DisplayText) ? HashCode.Combine(Perk.DisplayText, Perk.DisplayTextColor, Perk.DisplayTextOpacity) : 0f;
var transformHash = HashCode.Combine(Angle, Perk.IconAngleOffset, Perk.IconScale);
return HashCode.Combine(
Perk.Level,
fadeHash,
textHash,
Manager.Instance.IsGameOver,
cooldownHash,
highlightHash,
transformHash
);
}
protected override void OnClick(MousePanelEvent e)
{
// if non-local player is selected, do nothing
if (Manager.Instance.SelectedPlayer.IsValid())
return;
var player = Manager.Instance.LocalPlayer;
if ( !player.IsValid() )
return;
if( player.Stats[PlayerStat.ClickAddPerk] > 0f )
{
var perkType = TypeLibrary.GetType( typeof( PerkClickAdd ) );
if ( player.HasPerk( perkType ) )
{
var perk = player.GetPerk( perkType ) as PerkClickAdd;
if ( perk != null )
{
perk.Activate(TypeLibrary.GetType(Perk.GetType()));
Manager.Instance.HoveredPerkType = null;
Manager.Instance.IsHoveredPerkBanished = false;
e.StopPropagation();
}
}
}
}
protected override void OnRightClick(MousePanelEvent e)
{
// if non-local player is selected, do nothing
if (Manager.Instance.SelectedPlayer.IsValid())
return;
var player = Manager.Instance.LocalPlayer;
if ( !player.IsValid() )
return;
if( player.Stats[PlayerStat.ClickRemovePerk] > 0f )
{
var perkType = TypeLibrary.GetType( typeof( PerkClickRemove ) );
if ( player.HasPerk( perkType ) )
{
var perk = player.GetPerk( perkType ) as PerkClickRemove;
if ( perk != null )
{
perk.Activate(TypeLibrary.GetType(Perk.GetType()));
Manager.Instance.HoveredPerkType = null;
Manager.Instance.IsHoveredPerkBanished = false;
e.StopPropagation();
}
}
}
// player.LevelDownPerk(TypeLibrary.GetType(Perk.GetType()));
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits PanelComponent
@attribute [StyleSheet("PerkWorldPanel.razor.scss")]
@if(PerkItem.ShouldHidePanel)
{
return;
}
<root>
@{
var bgColor = PerkManager.GetCardRarityColor(PerkItem.PerkRarity, PerkItem.Curse);
}
<panel class="rarity" style="background-color:@(bgColor.Rgba);">
<panel class="icon" style="background-image: url(@(PerkItem.IconPath)); " />
</panel>
</root>
@code
{
[Property] public PerkItem PerkItem { get; set; }
protected override int BuildHash()
{
return HashCode.Combine(Time.Now);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerIcon.razor.scss")]
@{
var borderColor = Manager.Instance.SelectedPlayer.IsValid()
? Color.Lerp(Color.White, new Color(0.5f, 0.5f, 1f), Utils.FastSin(Time.Now * 24f))
: Color.White;
}
<root style="border: @(Player == Manager.Instance.SelectedPlayer ? 1 : 0)px solid @(borderColor.WithAlpha(0.75f).Rgba);">
<div class="icon" style="opacity:@((Player == Manager.Instance.SelectedPlayer ? 1f : 0.5f) * (Player.IsDead ? 0.1f : 1f) * (IsHovering ? 1f : 0.8f));">
<img src="avatar:@Player.Network.Owner.SteamId" />
<div class="hp_bg"></div>
<div class="hp_delta" style="width:@((Player.Health / Player.GetSyncStat(PlayerStat.MaxHp)) * 100f)%;"></div>
<div class="hp_fill" style="width:@((Player.Health / Player.GetSyncStat(PlayerStat.MaxHp)) * 100f)%;"></div>
</div>
<div class="player_level">@(Player.Level)</div>
</root>
@code
{
public Player Player { get; set; }
public bool IsHovering;
protected override void OnClick(MousePanelEvent e)
{
base.OnClick(e);
if (Manager.Instance.SelectedPlayer == Player)
Manager.Instance.SelectedPlayer = null;
else
Manager.Instance.SelectedPlayer = Player;
e.StopPropagation();
}
protected override void OnMouseOver(MousePanelEvent e)
{
if(e.Target != this)
return;
Manager.Instance.HoveredPlayerIcon = Player;
IsHovering = true;
}
protected override void OnMouseOut(MousePanelEvent e)
{
Manager.Instance.HoveredPlayerIcon = null;
IsHovering = false;
}
protected override int BuildHash()
{
return HashCode.Combine(RealTime.Now);
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Text.Json;
@inherits Panel
@attribute [StyleSheet("PlayerProfilePanel.razor.scss")]
<root>
<div class="hide_button" onclick=@(() => HideProfile())></div>
@if(PlayerStats is null)
{
@* <div class="loading">
<label>@($"Loading...")</label>
</div> *@
return;
}
<div class="name_container">
<div class="avatar" style="background-image: url( avatar:@Manager.Instance.PlayerProfileToShow.steamId )"></div>
@{
var displayName = Manager.Instance.PlayerProfileToShow.displayName;
}
<div class="displayName">@displayName</div>
</div>
<div class="main-content">
@* @if( _isLoadingPerks)
{
<div class="loading_perks">@($"Loading perks...")</div>
}
else
{
if(_perkPickPercents.Count > 0)
{
<div class="perks_container">
<div class="perks_title">@($"Favorite Perks:")</div>
<div class="perks">
@{
foreach(var pair in _perkPickPercents.OrderByDescending(x => x.Value).Take(10))
{
var perkType = pair.Key;
<PerkIconStatic style="height: 100%;" PerkType=@perkType Level=@(1) HideLevel=@true [email protected] />
}
}
</div>
</div>
}
} *@
@* <div class="stats_title">
Stats
</div> *@
<div class="stats">
<DifficultyPanel DontChangeGameDifficulty=@true />
@{
var numRuns = GetStatSum(StatType.NumRuns);
var numWins = GetStatSum(StatType.NumVictory);
// var numLosses = GetStatSum(StatType.NumDefeat);
// var numResets = Math.Max(numRuns - (numWins + numLosses), 0);
var winPercent = numRuns > 0 ? (numWins / (float)numRuns) * 100f : 0f;
string winRateString;
if(winPercent >= 1f) winRateString = $"{winPercent:0.#}%";
else if(winPercent >= 0.1f) winRateString = $"{winPercent:0.##}%";
else if(winPercent >= 0.01f) winRateString = $"{winPercent:0.###}%";
else if(winPercent > 0f) winRateString = $"{winPercent:0.####}%";
else winRateString = "0%";
var numKills = GetStatSum(StatType.NumKills);
var numMinibossKills = GetStatSum(StatType.NumMinibossKills);
var fastestWinScore = GetStatMax(StatType.LeaderboardRun);
var hasFastestWin = fastestWinScore > Manager.VICTORY_OFFSET / 2f;
var fastestWinTime = hasFastestWin ? Manager.VICTORY_OFFSET - fastestWinScore : 0f;
var fastestWinString = hasFastestWin ? Utils.FormatTime(fastestWinTime) : "...";
var i = 0;
PlayerStatsToShow.Clear();
PlayerStatsToShow.Add(new PlayerProfileStatData("Victories", numWins.ToString("N0"), new Color(1f, 1f, 0.5f), "win", GetFontSizeForNumber(numWins)));
PlayerStatsToShow.Add(new PlayerProfileStatData("Fastest Victory", fastestWinString, new Color(0.5f, 1f, 0.5f), "clock"));
PlayerStatsToShow.Add(new PlayerProfileStatData("Attempts", numRuns.ToString("N0"), new Color(0.8f), "num_runs", GetFontSizeForNumber(numRuns)));
PlayerStatsToShow.Add(new PlayerProfileStatData("Winrate", winRateString, new Color(0.6f, 0.6f, 0.9f, 0.7f), "win_rate"));
PlayerStatsToShow.Add(new PlayerProfileStatData("Enemy Kills", numKills.ToString("N0"), new Color(0.8f, 0.2f, 0.2f), "kill", GetFontSizeForNumber(numKills)));
PlayerStatsToShow.Add(new PlayerProfileStatData("Miniboss Kills", numMinibossKills.ToString("N0"), new Color(0.9f, 0.4f, 0.1f), "kill_miniboss", GetFontSizeForNumber(numMinibossKills)));
}
<div class="list_container">
@foreach(var data in PlayerStatsToShow)
{
<div class="flat list" style="background-color: @((i % 2 == 0 ? new Color(0, 0, 0, 0.4f) : new Color(0, 0, 0, 0.7f)).Rgba);">
<div>
<div class="icon_container">
<label class="icon" style="background-color:@(data.color.Rgba); mask-image:@($"url(/textures/ui/stats/{data.icon}.png)")"></label>
</div>
<label class="bold stat_name" style="font-size:@(data.title.Length > 15 ? Math.Round(Utils.Map(data.title.Length, 15, 36, 14f, 10f, EasingType.SineIn)) : 14)px; color:@(Color.Lerp(data.color, Color.White, (i % 2 == 0 ? 0.5f : 0.4f)).Rgba);">@(data.title)</label>
</div>
<div class="values">
@{
var color = (data.text == "0" || data.text == "0%" || data.text == "...")
? Color.Lerp(data.color, new Color(0.5f), 0.75f).WithAlpha(0.5f).Rgba
: data.color.Rgba;
}
<label class="bold stat_value" style="color:@(color); font-size:@(data.fontSize)px;">@(data.text)</label>
</div>
</div>
@{
i++;
}
}
</div>
</div>
</div>
</root>
@code
{
public struct PlayerProfileStatData
{
public string title;
public string text;
public Color color;
public string icon;
public float fontSize;
public PlayerProfileStatData(string _title, string _text, Color _color, string _icon, float _fontSize = 16f)
{
title = _title;
text = _text;
color = _color;
icon = _icon;
fontSize = _fontSize;
}
}
public List<PlayerProfileStatData> PlayerStatsToShow { get; set; } = new();
private bool _isLoadingPerks;
private long _loadedSteamId;
public Sandbox.Services.Stats.PlayerStats PlayerStats { get; set; }
private Dictionary<TypeDescription, float> _perkPickPercents = new();
protected override void OnAfterTreeRender(bool firstTime)
{
base.OnAfterTreeRender(firstTime);
var currentSteamId = Manager.Instance.PlayerProfileToShow?.steamId ?? 0;
if (firstTime || currentSteamId != _loadedSteamId)
{
_loadedSteamId = currentSteamId;
Refresh();
}
}
async void Refresh()
{
PlayerStats = null;
StateHasChanged();
_isLoadingPerks = true;
PlayerStats = Sandbox.Services.Stats.GetPlayerStats("facepunch.ss2", Manager.Instance.PlayerProfileToShow.steamId);
await PlayerStats.Refresh();
_isLoadingPerks = false;
_perkPickPercents.Clear();
/*
foreach(var type in TypeLibrary.GetTypes<Perk>())
{
var attrib = type.GetAttribute<PerkAttribute>();
if(attrib == null)
continue;
if(attrib.Disabled)
continue;
if(attrib.Curse)
continue;
var timesChosen = (int)PlayerStats.Get(Manager.GetPerkStatString(StatType.PerkChosen, type)).Sum;
var timesIgnored = (int)PlayerStats.Get(Manager.GetPerkStatString(StatType.PerkIgnored, type)).Sum;
// if( timesChosen + timesIgnored > 0 && (timesChosen > 2 || timesIgnored > 3) )
if( timesChosen >= 2 )
{
var percent = MathX.Clamp(timesChosen / (float)(timesChosen + timesIgnored), 0f, 1f);
// Log.Info($"{type}: chosen {timesChosen}, ignored {timesIgnored} - percent: {percent}");
_perkPickPercents[type] = percent;
}
}
*/
// Log.Info($"Loaded {_perkPickPercents.Count} perks for player profile.");
// var totalWinTime = 0f;
// for(int difficulty = 1; difficulty <= Manager.MaxDifficulty; difficulty++)
// {
// }
StateHasChanged();
// var zombies = stats.Get("zombies_killed");
// Log.Info($"Garry has killed {zombies.Sum} zombies!");
// Log.Info($"Refreshed leaderboard with {Leaderboard.Entries.Count()} entries.");
}
protected override int BuildHash()
{
return HashCode.Combine(
DifficultyPanel.DifficultyToDisplay,
Manager.Instance.PlayerProfileToShow
);
}
public void HideProfile()
{
Manager.Instance.PlayerProfileToShow = null;
}
public int GetStatSum( StatType statType )
{
if( DifficultyPanel.DifficultyToDisplay == -1 )
{
int total = 0;
for(int diff = Manager.MinDifficulty; diff <= Manager.MaxDifficulty; diff++)
{
total += (int)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, diff)).Sum;
}
return total;
}
else
{
return (int)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, DifficultyPanel.DifficultyToDisplay)).Sum;
}
}
public float GetStatMax( StatType statType )
{
if( DifficultyPanel.DifficultyToDisplay == -1 )
{
float max = 0f;
for(int diff = Manager.MinDifficulty; diff <= Manager.MaxDifficulty; diff++)
{
var val = (float)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, diff)).Max;
if(val > max)
max = val;
}
return max;
}
else
{
return (float)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, DifficultyPanel.DifficultyToDisplay)).Max;
}
}
public static float GetFontSizeForNumber( float number )
{
if(number >= 1000000000f) return 10f;
if(number >= 100000000f) return 11f;
if(number >= 10000000f) return 12f;
if(number >= 1000000f) return 14f;
return 16f;
}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerTooltip.razor.scss")]
<root>
@{
string name = "";
}
@if(Player != null )
{
@if(ShowIcon)
{
<div class="icon">
<img src="avatar:@Player.Network.Owner.SteamId" />
<div class="level">@Player.Level</div>
</div>
}
@{
name = Player?.Network.Owner?.DisplayName ?? "";
}
}
else
{
name = Name ?? "";
}
<div class="name">@name</div>
@if(Player != null && Player.Network.Owner != null)
{
<div class="ping">@($"{Player.Network.Owner.Ping:0}ms")</div>
}
</root>
@code {
public Player Player { get; set; }
public bool ShowIcon { get; set; }
public string Name { get; set; }
public override void Tick()
{
SetClass( "hidden", Input.UsingController );
var mousePos = Mouse.Position;
if (mousePos.x > Screen.Width * 0.9f)
{
mousePos += new Vector2(-20f, 20f);
var mousePosRelative = mousePos / Screen.Size;
Style.Right = Length.Fraction(1f - mousePosRelative.x);
Style.Top = Length.Fraction(mousePosRelative.y);
}
else
{
mousePos += new Vector2(20f, 20f);
var mousePosRelative = mousePos / Screen.Size;
Style.Left = Length.Fraction(mousePosRelative.x);
Style.Top = Length.Fraction(mousePosRelative.y);
}
}
protected override int BuildHash()
{
return HashCode.Combine(RealTime.Now);
}
}
@namespace Sandbox
@inherits Panel
@using Sandbox.UI
@implements IRichTextPanel
@attribute [RichTextPanel( @"([+-]?\d+(?:\.\d+)?[%ms]?)[ \t]*(?:→|->)[ \t]*([+-]?\d+(?:\.\d+)?[%ms]?)", UseCaptureGroup = false )]
<root class="richtext-arrow-result">
<label class="val val1" style="color: @(Color.Hex);">@Text1</label>
<label>→</label>
<label class="val val2" style="color: @(Color.Hex);">@Text2</label>
</root>
<style>
.val1
{
opacity: 0.2;
}
</style>
@code
{
public virtual Color Color => Color.Green;
public float? ImageSize { get; set; }
string Text1;
string Text2;
public void ParseRichText(string text)
{
// Log.Info( "Parsing arrow result: " + text );
var split = text.Split( "->");
if(split.Length == 1)
{
split = text.Split( "→" );
}
if(split.Length == 2)
{
Text1 = split[0];
Text2 = split[1];
if ( Text1.StartsWith( "[-]" ) || Text1.StartsWith( "[+]" ) )
{
Text1 = Text1[3..];
}
if ( Text2.EndsWith( "[/-]" ) || Text2.EndsWith( "[/+]" ) )
{
Text2 = Text2[..^4];
}
if(Text1.StartsWith("+") && !Text2.StartsWith("-") && !Text2.StartsWith("+"))
{
Text2 = "+" + Text2;
}
}
else
{
Text1 = text;
Text2 = "";
}
}
protected override int BuildHash()
{
return System.HashCode.Combine( Text1, Text2, Color );
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<root>
<div class="menu-icon-toggle-group">
<IconPanel Tooltip="Compact" class=@( Size == 60 ? "active" : "" ) Text="view_compact" @onclick=@( () => ChangeSize( 60 ) )></IconPanel>
<IconPanel Tooltip="Medium" class=@( Size == 120 ? "active" : "" ) Text="view_module" @onclick=@( () => ChangeSize( 120 ) )></IconPanel>
<IconPanel Tooltip="Large" class=@( Size == 200 ? "active" : "" ) Text="grid_view" @onclick=@( () => ChangeSize( 200 ) )></IconPanel>
</div>
</root>
@code
{
public int Size { get; set; } = 120;
protected override int BuildHash() => HashCode.Combine( Size );
void ChangeSize( int size )
{
Size = size;
}
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox
<root>
@if ( Current is not null )
{
var mouse = Mouse.Position;
var left = mouse.x * Panel.ScaleFromScreen - 32;
var top = mouse.y * Panel.ScaleFromScreen - 32;
var iconStyle = string.IsNullOrEmpty( Current.Icon ) ? "" : $"background-image: url({Current.Icon}); ";
<div class="dragging" style="@(iconStyle)left: @(left)px; top: @(top)px;" @ref="DragVisual">
@if ( string.IsNullOrEmpty( Current.Icon ) )
{
<div class="drag-title">@Current.Title</div>
}
</div>
}
</root>
@code
{
public static DragHandler Instance { get; private set; }
/// <summary>
/// The data currently being dragged, or null.
/// </summary>
public static DragData Current { get; private set; }
/// <summary>
/// True if something is actively being dragged.
/// </summary>
public static bool IsDragging => Current is not null;
Panel DragVisual { get; set; }
private RootPanel _rootPanel;
protected override void OnStart()
{
Instance = this;
}
protected override void OnDestroy()
{
if ( Instance == this )
Instance = null;
}
/// <summary>
/// Start dragging with the given data.
/// </summary>
public static void StartDragging( DragData data )
{
Current = data;
data.Source.UserData = data;
Instance?.StateHasChanged();
}
/// <summary>
/// Stop dragging and clear all state.
/// </summary>
public static void StopDragging()
{
if (!IsDragging) return;
Current = null;
Instance?.StateHasChanged();
}
protected override void OnUpdate()
{
if ( !IsDragging ) return;
if ( !_rootPanel.WantsMouseInput() )
{
StopDragging();
return;
}
if ( DragVisual is not null )
{
var mouse = Mouse.Position;
DragVisual.Style.Left = Length.Pixels( mouse.x * Panel.ScaleFromScreen - 32 );
DragVisual.Style.Top = Length.Pixels( mouse.y * Panel.ScaleFromScreen - 32 );
}
}
protected override void OnTreeBuilt()
{
_rootPanel = Panel.FindRootPanel();
}
protected override int BuildHash() => HashCode.Combine( Current );
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
@attribute [SpawnMenuHost.SpawnMenuMode]
@attribute [Icon( "🎨" )]
@attribute [Title( "#spawnmenu.mode.effects" )]
@attribute [Order( -75 )]
<root>
<EffectsList />
<EffectsProperties />
</root>
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel
<root>
<div class="presets-toggle" @onclick=@OnToggleClick>
<i>expand_less</i>
</div>
</root>
@code
{
public PlayerInventory Inventory { get; set; }
void OnToggleClick()
{
var menu = MenuPanel.Open( this );
var presets = PlayerLoadout.GetLoadoutPresets();
foreach ( var preset in presets )
{
var name = preset.Name;
var json = preset.LoadoutJson;
menu.AddSubmenu( "bookmark", name, sub =>
{
sub.AddOption( "play_arrow", "Load", () => OnLoadPreset( json ) );
sub.AddOption( "save", "Overwrite with current", () => OnOverwritePreset( name ) );
sub.AddOption( "close", "Delete", () => OnDeletePreset( name ) );
} );
}
if ( presets.Any() )
{
menu.AddSpacer();
}
menu.AddOption( "refresh", "Reset to Default", ResetToDefault );
menu.AddOption( "add", "New Preset", OnSaveNew );
menu.StateHasChanged();
}
void OnLoadPreset( string json )
{
var loadout = GetLoadout();
if ( !loadout.IsValid() ) return;
loadout.SwitchToPreset( json );
}
void OnOverwritePreset( string name )
{
var json = LocalData.Get<string>( "hotbar" );
if ( string.IsNullOrEmpty( json ) ) return;
PlayerLoadout.SaveLoadoutPreset( name, json );
}
void OnDeletePreset( string name )
{
PlayerLoadout.DeleteLoadoutPreset( name );
}
void ResetToDefault()
{
var loadout = GetLoadout();
if ( !loadout.IsValid() ) return;
loadout.ResetToDefault();
}
void OnSaveNew()
{
var popup = new StringQueryPopup
{
Title = "New Inventoy Preset",
Placeholder = "Enter a name...",
ConfirmLabel = "Save",
OnConfirm = OnSaveConfirmed,
Parent = FindRootPanel()
};
}
void OnSaveConfirmed( string name )
{
var json = LocalData.Get<string>( "hotbar" );
if ( string.IsNullOrEmpty( json ) ) return;
PlayerLoadout.SaveLoadoutPreset( name, json );
}
PlayerLoadout GetLoadout()
{
return Inventory?.GetComponent<PlayerLoadout>();
}
}