ui/QuestPanel.razor

A Razor UI panel for displaying quests and run progress. It builds lists of quest and achievement rows, shows tabs, progress bars, rewards, supports collecting rewards, debug reset buttons, scrolling, and a run-progress animation mode.

File Access
@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
		);
	}
}