ui/QuestPanel.razor

A Razor UI panel that displays quests and run progress. It builds quest and achievement rows from ProgressManager, shows tabs, progress bars, rewards, supports collecting rewards, scrolling, simple animations, and debug reset buttons.

Networking
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@inherits Panel
@attribute [StyleSheet("QuestPanel.razor.scss")]

<root>
	@if(RunProgressMode)
	{
		<div class="continue_btn" onclick=@(() => Manager.Instance.ContinueToGameOverScreen())>Continue</div>
	}
	else
	{
		<div class="hide_button" onclick=@(() => Close())></div>
	}
	<div class="title_label">@(RunProgressMode ? "Run Progress" : "Quests")</div>

	<div class="tabs">
		@for(int t = 0; t < TabNames.Length; t++)
		{
			var tabIndex = t;
			if(RunProgressMode && !_visibleRunProgressTabs.Contains(tabIndex)) continue;
			var readyCount = AllQuests[tabIndex].Count(q => q.IsReady);
			<div class="tab @(_selectedTab == tabIndex ? "active" : "")" onclick=@(() => SelectTab(tabIndex))>
				@TabNames[tabIndex]
				@if(readyCount > 0)
				{
					<div class="tab_badge">@readyCount</div>
				}
			</div>
		}
	</div>

	@if(!RunProgressMode)
	{
		<div class="debug_buttons">
			<div class="debug_btn" onclick=@(() => DebugResetQuestLevels())>Reset Quest Levels</div>
			<div class="debug_btn" onclick=@(() => DebugResetStats())>Reset Stats</div>
		</div>
	}

	<div class="entries_container">
		<div class="entries">
			@{
				var quests = AllQuests[_selectedTab];
			}
			@if(RunProgressMode && quests.Count == 0)
			{
				<div class="empty_label">No progress this run.</div>
			}
			@for(int i = StartIndex; i < Math.Min(StartIndex + MaxVisible, quests.Count); i++)
			{
				var quest = quests[i];
				var pct = Math.Clamp(quest.Current / (float)quest.Target, 0f, 1f);
				var bgColor = i % 2 == 0 ? "background-color:#00000077;" : "";
				var readyClass = quest.IsReady ? "ready" : "";
				var dimmedClass = IsDimmed(quest) ? "dimmed" : "";
				var questCapture = quest;

				<div class="entry @readyClass @dimmedClass" style="@bgColor" onclick=@(() => OnCollectReward(questCapture))>
					<div class="quest_info">
						<label class="quest_name">@quest.Name</label>
						<label class="quest_desc">@quest.Description</label>
					</div>
					<div class="progress_container">
						@if(RunProgressMode)
						{
							var beforePct = Math.Clamp(quest.ProgressBefore / (float)quest.Target, 0f, 1f);
							var fillPct = _animate ? pct : beforePct;
							<div class="progress_new" style="width:@((pct * 100f).ToString("0.#"))%;"></div>
							<div class="progress_fill @(_animate ? "animated" : "")" style="width:@((fillPct * 100f).ToString("0.#"))%; background-color:@quest.Color.Rgba;"></div>
						}
						else
						{
							<div class="progress_fill" style="width:@((pct * 100f).ToString("0.#"))%; background-color:@quest.Color.Rgba;"></div>
						}
						<label class="progress_text">@Math.Min(quest.Current, quest.Target) / @quest.Target</label>
					</div>
					<div class="reward @(quest.IsCompleted ? "completed" : "")">
						<div class="reward_icon" style="background-image:url(@quest.RewardIcon);"></div>
						<label class="reward_amount">@quest.RewardAmount</label>
					</div>
				</div>
			}
		</div>

		@{
			var questList = AllQuests[_selectedTab];
			if(questList.Count > MaxVisible)
			{
				var scrollT = StartIndex / (float)(questList.Count - MaxVisible);
				var containerH = 580f;
				var barH = (MaxVisible / (float)questList.Count) * containerH;
				var barTop = scrollT * (containerH - barH);
				<div class="scrollbar" style="top:@(barTop)px; height:@(barH)px;"></div>
			}
		}
	</div>
</root>

@code
{
	public struct QuestData
	{
		public string Name;
		public int Current;
		public int Target;
		public Color Color;
		public QuestId Id;
		public bool IsReady;
		public bool IsCompleted;
		public int RewardAmount;
		public string RewardIcon;
		public string Description;
		public string AchievementName; // non-null for achievement rows
		public int ProgressBefore;     // for RunProgressMode: progress at run start

		public QuestData(string name, string description, int current, int target, Color color,
		                 QuestId id, bool isReady, bool isCompleted,
		                 int rewardAmount, string rewardIcon, string achievementName = null)
		{
			Name = name; Description = description; Current = current; Target = target; Color = color;
			Id = id; IsReady = isReady; IsCompleted = isCompleted;
			RewardAmount = rewardAmount; RewardIcon = rewardIcon;
			AchievementName = achievementName;
			ProgressBefore = 0;
		}
	}

	string[] TabNames => ProgressManager.QuestTabNames;

	public bool RunProgressMode { get; set; }

	int _selectedTab = 0;
	static int StartIndex = 0;
	static float _scrollPos = 0f;
	static float _scrollVelocity = 0f;
	int MaxVisible => 14;
	bool _animate = false;
	int _pendingAnimateFrames = 0;

	// Track quests/achievements collected this session so they stay visible after collection.
	HashSet<QuestId> _collectedQuestIds = new();
	HashSet<string> _collectedAchievementNames = new();

	// Frozen at panel open: which quests/achievements were fully done at that moment.
	// Used so completing a quest mid-session doesn't move it to the bottom until re-open.
	HashSet<string> _dimmedAtOpen = new();

	// Tabs visible at panel open in run progress mode — frozen so collecting rewards doesn't hide tabs.
	HashSet<int> _visibleRunProgressTabs = new();

	List<List<QuestData>> AllQuests = Enumerable.Range(0, ProgressManager.QuestTabNames.Length).Select(_ => new List<QuestData>()).ToList();

	protected override void OnAfterTreeRender(bool firstTime)
	{
		base.OnAfterTreeRender(firstTime);
		BuildQuestData();
		if(firstTime)
		{
			StartIndex = 0;
			_scrollPos = 0f;
			_scrollVelocity = 0f;

			// Capture which entries are fully done at open time, then sort them to the bottom.
			_dimmedAtOpen.Clear();
			foreach(var tabList in AllQuests)
				foreach(var q in tabList)
					if(IsDimmed(q))
						_dimmedAtOpen.Add(GetDimKey(q));
			if(_dimmedAtOpen.Count > 0)
				for(int t = 0; t < AllQuests.Count; t++)
					AllQuests[t] = AllQuests[t].OrderBy(q => _dimmedAtOpen.Contains(GetDimKey(q))).ToList();

			if(RunProgressMode)
			{
				_visibleRunProgressTabs.Clear();
				bool selectedSet = false;
				for(int t = 0; t < TabNames.Length; t++)
				{
					if(AllQuests[t].Count > 0)
					{
						_visibleRunProgressTabs.Add(t);
						if(!selectedSet) { _selectedTab = t; selectedSet = true; }
					}
				}
				_pendingAnimateFrames = 3;
			}
		}
	}

	static string GetDimKey(QuestData q) => q.AchievementName ?? q.Id.ToString();
	static bool IsDimmed(QuestData q) => q.IsCompleted && !q.IsReady;

	void BuildQuestData()
	{
		AllQuests = Enumerable.Range(0, TabNames.Length)
			.Select(_ => new List<QuestData>()).ToList();

		foreach (var def in ProgressManager.Quests)
		{
			bool isReady = ProgressManager.IsQuestReadyToCollect(def.Id);
			float gained = 0f;
			if(RunProgressMode)
			{
				gained = ProgressManager.GetStatGainedThisRun(def.Stat);
				if(gained <= 0f && !_collectedQuestIds.Contains(def.Id) && !isReady) continue;
			}

			var (current, target) = ProgressManager.GetQuestProgress(def.Id);
			bool isCompleted = ProgressManager.IsQuestCompleted(def.Id);
			int level = ProgressManager.GetQuestLevel(def.Id);

			var name = string.Format(def.Name, target);
			if (def.LevelTargets.Length > 1 && level > 0 && !isCompleted)
				name += $" Lv.{level + 1}";
			if (isCompleted)
				name += " \u2713";

			var qd = new QuestData(
				name, def.Description, (int)current, target, def.Color,
				def.Id, isReady, isCompleted,
				ProgressManager.GetCurrentRewardAmount(def.Id), def.RewardIcon);

			if(RunProgressMode)
				qd.ProgressBefore = (int)Math.Clamp(current - gained, 0f, target);

			AllQuests[def.Tab].Add(qd);
		}

		foreach (var ach in ProgressManager.Achievements)
		{
			bool claimed = ProgressManager.IsAchievementClaimed(ach.Name);
			bool unlocked = ProgressManager.IsAchievementUnlocked(ach.Name);
			if(RunProgressMode && !ProgressManager.AchievementsUnlockedThisRun.Contains(ach.Name) && !unlocked && !_collectedAchievementNames.Contains(ach.Name))
				continue;
			var name = claimed ? ach.DisplayName + " \u2713" : ach.DisplayName;
			AllQuests[4].Add(new QuestData(
				name, ach.Description, claimed || unlocked ? 1 : 0, 1, ach.Color,
				default, unlocked, claimed,
				ach.RewardAmount, ach.RewardIcon, ach.Name));
		}

		if(_dimmedAtOpen.Count > 0)
		{
			for(int t = 0; t < AllQuests.Count; t++)
				AllQuests[t] = AllQuests[t].OrderBy(q => _dimmedAtOpen.Contains(GetDimKey(q))).ToList();
		}
	}

	void OnCollectReward(QuestData quest)
	{
		if (!quest.IsReady) return;
		if (quest.AchievementName != null)
		{
			ProgressManager.ClaimAchievementReward(quest.AchievementName);
			_collectedAchievementNames.Add(quest.AchievementName);
			Log.Info($"Achievement reward claimed: {quest.RewardAmount}x for {quest.AchievementName}");
		}
		else
		{
			ProgressManager.CollectQuestReward(quest.Id);
			_collectedQuestIds.Add(quest.Id);
			Log.Info($"Quest reward collected: {quest.RewardAmount}x {quest.RewardIcon} for {quest.Id}");
		}
		BuildQuestData();
		if(RunProgressMode && quest.AchievementName == null && !ProgressManager.IsQuestCompleted(quest.Id))
		{
			_animate = false;
			_pendingAnimateFrames = 3;
		}
		StateHasChanged();
	}

	void DebugResetQuestLevels()
	{
		ProgressManager.DebugResetQuestLevels();
		BuildQuestData();
		StateHasChanged();
	}

	void DebugResetStats()
	{
		ProgressManager.DebugResetStats();
		BuildQuestData();
		StateHasChanged();
	}


	void SelectTab(int tabIndex)
	{
		if(_selectedTab == tabIndex) return;
		_selectedTab = tabIndex;
		StartIndex = 0;
		_scrollPos = 0f;
		_scrollVelocity = 0f;
		if(RunProgressMode)
		{
			_animate = false;
			_pendingAnimateFrames = 3;
		}
		StateHasChanged();
	}

	public override void OnMouseWheel(Vector2 value)
	{
		base.OnMouseWheel(value.y);
		_scrollVelocity = Math.Clamp(_scrollVelocity + value.y * 1.5f, -15f, 15f);
	}

	public override void Tick()
	{
		base.Tick();

		if(_pendingAnimateFrames > 0)
		{
			_pendingAnimateFrames--;
			if(_pendingAnimateFrames == 0)
			{
				_animate = true;
			}
			StateHasChanged();
		}

		if(MathF.Abs(_scrollVelocity) < 0.05f)
		{
			_scrollVelocity = 0;
			return;
		}

		var questList = AllQuests?[_selectedTab];
		if(questList == null) return;

		var maxPos = Math.Max(0, questList.Count - MaxVisible);
		_scrollPos = Math.Clamp(_scrollPos + _scrollVelocity, 0, maxPos);
		_scrollVelocity *= 0.85f;

		var newIndex = (int)_scrollPos;
		if(newIndex != StartIndex)
		{
			StartIndex = newIndex;
			StateHasChanged();
		}
	}

	void Close()
	{
		Manager.Instance.ShowQuestPanel = false;
	}

	protected override int BuildHash()
	{
		return System.HashCode.Combine(
			Manager.Instance.ShowQuestPanel,
			Manager.Instance.ShouldShowQuestProgressPanel,
			_selectedTab,
			StartIndex,
			_animate,
			ProgressManager.StateVersion
		);
	}
}