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