This commit is contained in:
iisaev committed 2026-10-18 12:41:39 +03:00
commit b9b508a1d0
24 files changed
+2201

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
mod_quests_base_name = Quests Engine
mod_quests_base_desc = Engine quests subsystem: the quest factory and runtime state, the IQuestService implementation, quest scenario actions, quest scene conditions and the quest journal UI.
ui/journal/new = New journal entry
ui/journal/updated = Quest updated
+4
View File
@@ -0,0 +1,4 @@
mod_quests_base_name = Движок квестов
mod_quests_base_desc = Подсистема квестов движка: фабрика квестов и их состояние, реализация IQuestService, сценарные действия, условия квестов в сценах и журнал квестов.
ui/journal/new = Новая запись в журнале
ui/journal/updated = Задание обновлено
+22
View File
@@ -0,0 +1,22 @@
{
"id": "quests_base",
"name": "Quests Engine",
"version": "1.0.0",
"author": "ct",
"description": "Engine quests subsystem: the quest factory and runtime state (QuestFactory, QuestBase/QuestData), the IQuestService implementation, quest scenario actions, quest scene conditions with the QuestParser leaf parser and the quest journal UI. Replaces the former Core/Quests state, Core/Actions quest services and actions, Core/Scenes quest conditions and Core/UI/Quests - QuestsModInitializer publishes the kernel contract (IQuestService via QuestServiceRegistry), registers the quest leaf parser and the new-game resettable; QuestsSaveable persists quest state through the generic mod save slot.",
"category": "engine, library",
"sources": [
"src/**/*.cs"
],
"depends": ["characters_base>=1", "interface>=1"],
"ui": [
{
"id": "journal",
"kind": "panel",
"slot": "ModBeforeUIPanel",
"scene_path": "scenes/ui/quest_journal.tscn",
"button": { "icon": "journal", "locale": "ui/journal/title", "index": 60 },
"priority": 10
}
]
}
+300
View File
@@ -0,0 +1,300 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://mods/quests_base/src/UI/Quests/QuestJournalBehaviour.cs" id="1_script"]
[node name="QuestJournal" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("1_script")
[node name="Bg" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Title" type="Label" parent="."]
layout_mode = 1
anchors_preset = 0
offset_left = 44.0
offset_top = 28.0
offset_right = 400.0
offset_bottom = 64.0
mouse_filter = 2
[node name="CloseButton" type="Button" parent="."]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -46.0
offset_top = 28.0
offset_right = -8.0
offset_bottom = 66.0
grow_horizontal = 0
focus_mode = 0
text = "✕"
[node name="BookArea" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 40.0
offset_top = 84.0
offset_right = -40.0
offset_bottom = -40.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Book" type="HBoxContainer" parent="BookArea"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
[node name="LeftPage" type="Panel" parent="BookArea/Book"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 1.0
mouse_filter = 1
[node name="PageGradient" type="TextureRect" parent="BookArea/Book/LeftPage"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="InnerBorder" type="Panel" parent="BookArea/Book/LeftPage"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 14.0
offset_top = 14.0
offset_right = -14.0
offset_bottom = -14.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Content" type="MarginContainer" parent="BookArea/Book/LeftPage"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 36
theme_override_constants/margin_top = 32
theme_override_constants/margin_right = 30
theme_override_constants/margin_bottom = 28
[node name="Column" type="VBoxContainer" parent="BookArea/Book/LeftPage/Content"]
layout_mode = 2
theme_override_constants/separation = 14
[node name="FilterTabs" type="HBoxContainer" parent="BookArea/Book/LeftPage/Content/Column"]
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
theme_override_constants/separation = 6
[node name="ActiveFilterBtn" type="Button" parent="BookArea/Book/LeftPage/Content/Column/FilterTabs"]
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
focus_mode = 0
[node name="CompletedFilterBtn" type="Button" parent="BookArea/Book/LeftPage/Content/Column/FilterTabs"]
custom_minimum_size = Vector2(0, 34)
layout_mode = 2
focus_mode = 0
[node name="TabsSpacer" type="Control" parent="BookArea/Book/LeftPage/Content/Column/FilterTabs"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="TocHeader" type="Label" parent="BookArea/Book/LeftPage/Content/Column"]
layout_mode = 2
theme_override_font_sizes/font_size = 14
[node name="EmptyLabel" type="Label" parent="BookArea/Book/LeftPage/Content/Column"]
visible = false
layout_mode = 2
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="BookArea/Book/LeftPage/Content/Column"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="QuestListContainer" type="VBoxContainer" parent="BookArea/Book/LeftPage/Content/Column/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 2
[node name="Spine" type="Panel" parent="BookArea/Book"]
custom_minimum_size = Vector2(56, 0)
layout_mode = 2
size_flags_vertical = 3
[node name="SpineShade" type="TextureRect" parent="BookArea/Book/Spine"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="RightPage" type="Panel" parent="BookArea/Book"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 1.0
mouse_filter = 1
[node name="PageGradient" type="TextureRect" parent="BookArea/Book/RightPage"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="InnerBorder" type="Panel" parent="BookArea/Book/RightPage"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 14.0
offset_top = 14.0
offset_right = -14.0
offset_bottom = -14.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Content" type="MarginContainer" parent="BookArea/Book/RightPage"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 44
theme_override_constants/margin_top = 80
theme_override_constants/margin_right = 38
theme_override_constants/margin_bottom = 28
[node name="Scroll" type="ScrollContainer" parent="BookArea/Book/RightPage/Content"]
layout_mode = 2
horizontal_scroll_mode = 0
[node name="Column" type="VBoxContainer" parent="BookArea/Book/RightPage/Content/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
[node name="EntryHeader" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column"]
layout_mode = 2
theme_override_font_sizes/font_size = 14
[node name="NameMargin" type="MarginContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column"]
layout_mode = 2
theme_override_constants/margin_top = 14
theme_override_constants/margin_bottom = 6
[node name="QuestNameLabel" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column/NameMargin"]
layout_mode = 2
theme_override_font_sizes/font_size = 30
autowrap_mode = 3
[node name="StageLabel" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column"]
layout_mode = 2
theme_override_font_sizes/font_size = 13
[node name="Divider" type="MarginContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column"]
layout_mode = 2
theme_override_constants/margin_top = 18
theme_override_constants/margin_bottom = 20
[node name="Row" type="HBoxContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="LineL" type="TextureRect" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row"]
custom_minimum_size = Vector2(30, 1)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Diamond" type="Panel" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row"]
custom_minimum_size = Vector2(9, 9)
layout_mode = 2
size_flags_vertical = 4
rotation = 0.7854
pivot_offset = Vector2(4.5, 4.5)
mouse_filter = 2
[node name="LineR" type="TextureRect" parent="BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row"]
custom_minimum_size = Vector2(30, 1)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="EntryText" type="RichTextLabel" parent="BookArea/Book/RightPage/Content/Scroll/Column"]
layout_mode = 2
size_flags_horizontal = 3
bbcode_enabled = true
fit_content = true
scroll_active = false
selection_enabled = false
autowrap_mode = 3
theme_override_font_sizes/normal_font_size = 16
theme_override_constants/line_separation = 7
[node name="SealsBlock" type="MarginContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column"]
visible = false
layout_mode = 2
theme_override_constants/margin_top = 28
[node name="Column" type="VBoxContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="SealsHeader" type="Label" parent="BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column"]
layout_mode = 2
theme_override_font_sizes/font_size = 14
[node name="SealsContainer" type="HFlowContainer" parent="BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column"]
layout_mode = 2
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 10
+43
View File
@@ -0,0 +1,43 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Adds a string tag to a quest. Tags are used for conditional logic in
/// scenario scripts — a quest can be checked for tag presence to branch
/// dialog. The tag is added via QuestBase.AddTag, which likely stores it
/// in the quest's save data.
/// </summary>
public class QuestAddTagAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.add_tag" — attaches a string tag to a
/// quest for use in conditional logic. Tags are queried by the
/// scenario DSL's "has_tag" condition to branch dialog or gate
/// quest progression. Both "quest_id" and "tag" dialog variables
/// are required — returns early with a warning if either is
/// missing.
/// </summary>
public string Name => "quest.add_tag";
/// <summary>
/// Reads "quest_id" and "tag" from dialog variables and delegates
/// to ctx.Quests.AddTag. Routes through QuestServiceImpl.TryGet —
/// missing quests are silently skipped. Tags are persisted with
/// save data and survive game restart.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var tag = ctx.GetParam("tag");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(tag))
{
ctx.Log("EVENT", "QuestAddTagAction: missing 'quest_id' or 'tag' variable");
return;
}
ctx.Quests.AddTag(questId, tag);
ctx.Log("QUEST", $"QuestAddTagAction: {questId}.AddTag('{tag}')");
}
}
}
@@ -0,0 +1,41 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Removes a string tag from a quest. Paired with QuestAddTagAction for
/// reversible quest state. Used in branching scenarios where a tag marks
/// a temporary condition that should be cleared when resolved.
/// </summary>
public class QuestRemoveTagAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.remove_tag" — the inverse of
/// QuestAddTagAction. Removes a tag from a quest to clear
/// temporary conditions (e.g. removing a "witnessed_event" tag
/// after the event has been resolved). Both "quest_id" and "tag"
/// are required.
/// </summary>
public string Name => "quest.remove_tag";
/// <summary>
/// Reads "quest_id" and "tag" from dialog variables and delegates
/// to ctx.Quests.RemoveTag. Removing a non-existent tag is a
/// no-op at the collection level. Missing quests are silently
/// skipped via TryGet.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var tag = ctx.GetParam("tag");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(tag))
{
ctx.Log("EVENT", "QuestRemoveTagAction: missing 'quest_id' or 'tag' variable");
return;
}
ctx.Quests.RemoveTag(questId, tag);
ctx.Log("QUEST", $"QuestRemoveTagAction: {questId}.RemoveTag('{tag}')");
}
}
}
@@ -0,0 +1,43 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Triggers a UI notification for a quest (e.g. "Quest Updated" popup).
/// This is the visual feedback companion to quest state changes — the
/// notification itself doesn't change quest state, it just informs the
/// player that something happened. Call this after SetStatus/SetDataStatus
/// to make the UI react.
/// </summary>
public class QuestSendNotificationAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.send_notification" — triggers a UI popup
/// for a quest (e.g. "Quest Updated" or "New Quest"). This is the
/// visual companion to state changes — call after SetStatus or
/// SetDataStatus so the player sees the update. The notification
/// itself does not modify quest state.
/// </summary>
public string Name => "quest.send_notification";
/// <summary>
/// Reads the "quest_id" variable and delegates to
/// ctx.Quests.SendNotification. The notification content is
/// derived from the quest's current state (title, description,
/// status) — ensure the quest state is updated before calling
/// this. Missing quests are silently skipped via TryGet.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
if (string.IsNullOrEmpty(questId))
{
ctx.Log("EVENT", "QuestSendNotificationAction: missing 'quest_id' variable");
return;
}
ctx.Quests.SendNotification(questId);
ctx.Log("QUEST", $"QuestSendNotificationAction: sent notification for '{questId}'");
}
}
}
@@ -0,0 +1,47 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Sets the numeric progress level within a quest (quest.Data.Status).
/// Unlike QuestSetStatusAction which changes the global quest state
/// (Active/Completed), this handles incremental progress — e.g. "kill 3/5
/// enemies". The "level" param is parsed as int (no fallback) so scenario
/// authors must ensure the value is always a valid integer.
/// </summary>
public class QuestSetDataStatusAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.set_data_status" — sets the numeric
/// progress level within a quest (quest.Data.Status). This is the
/// incremental counter (e.g. "2/5 wolves killed"), separate from
/// the global quest state managed by QuestSetStatusAction. The
/// "level" dialog variable is parsed as int with no fallback —
/// scenario authors must ensure it's always a valid integer.
/// </summary>
public string Name => "quest.set_data_status";
/// <summary>
/// Reads "quest_id" and "level" from dialog variables, parses
/// level as int, and delegates to ctx.Quests.SetDataStatus. No
/// bounds checking on the level value — the quest's data object
/// is responsible for validating the range. Missing quests are
/// silently skipped. Returns early with a warning if either
/// variable is missing.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var levelRaw = ctx.GetParam("level");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(levelRaw))
{
ctx.Log("EVENT", "QuestSetDataStatusAction: missing 'quest_id' or 'level' variable");
return;
}
var level = int.Parse(levelRaw);
ctx.Quests.SetDataStatus(questId, level);
ctx.Log("QUEST", $"QuestSetDataStatusAction: {questId}.Data.Status = {level}");
}
}
}
@@ -0,0 +1,44 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Sets the global state of a quest (e.g. "Active", "Completed", "Failed").
/// The "status" param is parsed via Enums&lt;QuestStatus&gt;.Parse, which is
/// case-insensitive — invalid status strings are caught by QuestServiceImpl's
/// TryGet pattern and logged as errors rather than crashing the dialog.
/// </summary>
public class QuestSetStatusAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.set_status" — sets the global lifecycle
/// state of a quest (Active, Completed, Failed, etc.). The
/// "status" dialog variable is parsed via
/// Enums&lt;QuestStatus&gt;.Parse as a case-insensitive
/// QuestStatus enum name (NotStarted / Progress / End). Invalid
/// status strings are
/// caught by QuestServiceImpl and logged as errors without
/// crashing the dialog.
/// </summary>
public string Name => "quest.set_status";
/// <summary>
/// Reads "quest_id" and "status" from dialog variables and
/// delegates to ctx.Quests.SetStatus. Missing quests are silently
/// skipped. Invalid status values are logged and ignored.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var statusRaw = ctx.GetParam("status");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(statusRaw))
{
ctx.Log("EVENT", "QuestSetStatusAction: missing 'quest_id' or 'status' variable");
return;
}
ctx.Quests.SetStatus(questId, statusRaw);
ctx.Log("QUEST", $"QuestSetStatusAction: {questId}.Status = {statusRaw}");
}
}
}
+151
View File
@@ -0,0 +1,151 @@
using System;
using Cthangover.Core.Events;
using Cthangover.Core.Quests;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Actions
{
/// <summary>
/// Quest service implementation with internal TryGet for safe fallback.
/// Public methods (SetStatus, SetDataStatus, AddTag, etc.) use TryGet which
/// catches KeyNotFoundException from QuestFactory — this prevents scenario
/// scripts from crashing when referencing non-existent quests. The separation
/// of SetStatus (global quest state like Active/Completed) from SetDataStatus
/// (numeric progress level) matches QuestBase's dual-state design. Exists()
/// returns false on any exception, so missing or broken quest data is treated
/// as "doesn't exist" rather than throwing.
/// </summary>
internal class QuestServiceImpl : IQuestService
{
/// <summary>
/// Direct lookup from QuestFactory by ID. Throws
/// KeyNotFoundException if the quest doesn't exist — use
/// Exists() or TryGet() when the quest may legitimately be
/// absent from the registry.
/// </summary>
public IQuest Get(string id) => QuestFactory.Instance.Get(id);
/// <summary>
/// Safe existence check. Wraps QuestFactory.Get in try/catch
/// and returns false on any exception — corrupted save data or
/// broken quest definitions are treated as "doesn't exist"
/// rather than propagating errors to the dialog engine.
/// </summary>
public bool Exists(string id)
{
try { return QuestFactory.Instance.Get(id) != null; }
catch { return false; }
}
/// <summary>
/// Internal safe-get pattern that catches KeyNotFoundException
/// from QuestFactory. Returns null for missing quests so
/// callers can use the null-conditional operator (?.) rather
/// than try/catch blocks. All mutating methods (SetStatus,
/// AddTag, etc.) route through TryGet to prevent scenario
/// scripts from crashing on broken quest references.
/// </summary>
public QuestBase TryGet(string id)
{
QuestBase result = null;
try
{
result = QuestFactory.Instance.Get(id);
}
catch(Exception ex)
{
GameLogger.Log("QUEST", $"try get quest '{id}' exception - {ex.Message}");
}
return result;
}
/// <summary>
/// Sets the global quest status (Active, Completed, Failed,
/// etc.) by parsing the status string via
/// Enums&lt;QuestStatus&gt;.Parse. Invalid status strings are
/// caught and logged as errors without throwing — the dialog
/// continues with the quest unchanged. Routes through TryGet
/// so missing quests are silently skipped. Publishes a
/// <see cref="QuestStatusChangedEvent"/> only when the status
/// actually changed; setting the same status is a no-op that
/// publishes nothing.
/// </summary>
public void SetStatus(string id, string status)
{
var quest = TryGet(id);
if(quest == null)
return;
try
{
var newStatus = Enums<QuestStatus>.Parse(status);
if (quest.Status == newStatus)
return;
var oldStatus = quest.Status.ToString();
quest.Status = newStatus;
ModEventBus.Publish(new QuestStatusChangedEvent
{
QuestId = id,
OldStatus = oldStatus,
NewStatus = status,
});
}
catch (Exception ex)
{
GameLogger.Log("QUEST", $"status '{status}' invalid - {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Updates the numeric progress level within the quest's data
/// object (quest.Data.Status). Separate from SetStatus which
/// controls the global lifecycle state. Routes through TryGet
/// with null-conditional access — if the quest doesn't exist,
/// the call is silently skipped. No bounds checking: the caller
/// is responsible for meaningful level values. The
/// <see cref="QuestDataStatusChangedEvent"/> is published by
/// <see cref="QuestData.SetStatus"/> itself.
/// </summary>
public void SetDataStatus(string id, int level)
{
TryGet(id)?.Data.SetStatus(level);
}
/// <summary>
/// Attaches a string tag to the quest's tag collection via
/// QuestBase.AddTag. Tags are persisted with save data and
/// queried by scenario conditions (has_tag). Adding a duplicate
/// tag is handled by the underlying collection. Routes through
/// TryGet for safe fallback on missing quests. The
/// <see cref="QuestTagChangedEvent"/> is published by
/// <see cref="QuestBase.AddTag"/> itself (change-only).
/// </summary>
public void AddTag(string id, string tag)
{
TryGet(id)?.AddTag(tag);
}
/// <summary>
/// Removes a tag from the quest. Used for clearing temporary
/// conditions in branching scenarios. Removing a non-existent
/// tag is a collection-level no-op. Routes through TryGet. The
/// <see cref="QuestTagChangedEvent"/> is published by
/// <see cref="QuestBase.RemoveTag"/> itself (change-only).
/// </summary>
public void RemoveTag(string id, string tag)
{
TryGet(id)?.RemoveTag(tag);
}
/// <summary>
/// Dispatches a UI notification for the quest (e.g. "Quest
/// Updated" popup) via QuestBase.SendNotification. Does not
/// modify quest state — call SetStatus/SetDataStatus before
/// this to ensure the notification reflects the new state.
/// Routes through TryGet.
/// </summary>
public void SendNotification(string id)
{
TryGet(id)?.SendNotification();
}
}
}
+29
View File
@@ -0,0 +1,29 @@
using Cthangover.Core.UI.Messages;
using Cthangover.Core.Quests;
using Godot;
using Cthangover.Mods.Interface.Messages;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Extension methods layered on top of <see cref="IQuest"/> so that
/// dialogue scripts and event handlers can trigger common side-effects
/// (such as notification banner display) without forcing every quest
/// implementation to carry UI dependencies.
/// </summary>
public static class IQuestExtensions
{
/// <summary>
/// Posts a localised "New Journal Entry" notification to the message
/// queue via <see cref="MessagesHelper"/>. The notification text
/// prepends the translation of the <c>"ui/journal/new"</c> key to the
/// translated quest <see cref="IQuest.Name"/>, producing a
/// journal‑style announcement that the in‑game HUD renders as a
/// floating banner.
/// </summary>
public static void SendNotification(this IQuest quest)
{
MessagesHelper.AddMessage(TranslationServer.Translate("ui/journal/new") + " - " + TranslationServer.Translate(quest.Name));
}
}
}
+123
View File
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Events;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Primary concrete implementation of <see cref="IQuest"/> that serves as
/// both the runtime quest instance (created by <see cref="QuestFactory"/>
/// from <see cref="QuestDefinition"/> data) and the serialization unit
/// written into save files. Because it is marked <c>[Serializable]</c>,
/// Godot's JSON serialization can round-trip the entire quest graph
/// — including the mutable <see cref="Data"/> payload — when the
/// player saves or loads a game. The <see cref="StatusToDescription"/>
/// dictionary maps raw <see cref="QuestStatus"/> integer values to
/// localised description strings that the journal UI renders for each
/// progression stage.
/// </summary>
[Serializable]
public class QuestBase : IQuest
{
/// <summary>
/// Unique string identifier matching the quest's definition key in
/// the mod data. Used by <see cref="QuestFactory.Get"/> for lookup
/// and preserved in save files so that loaded state can be reattached
/// to the correct definition.
/// </summary>
public string ID { get; set; }
/// <summary>
/// Localised display name shown in the quest journal and notification
/// banners. This value originates from the mod definition file and is
/// translated before display.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Current progression state of the quest. Defaults to
/// <see cref="QuestStatus.NotStarted"/>. This property is updated at
/// runtime by quest‑advancement logic and restored from save data
/// by <see cref="QuestFactory.SetAll"/>.
/// </summary>
public QuestStatus Status { get; set; } = QuestStatus.NotStarted;
/// <summary>
/// Mutable runtime payload holding the quest's numeric status and
/// the collection of string tags accumulated during gameplay. The
/// factory overwrites this object when restoring a save file via
/// <see cref="QuestFactory.SetAll"/>, so any modifications to tags
/// or status through <see cref="AddTag"/>, <see cref="RemoveTag"/>,
/// or <see cref="QuestData.SetStatus"/> persist until the next load.
/// </summary>
public QuestData Data { get; set; } = new QuestData();
/// <summary>
/// Lookup table that maps integer <see cref="QuestStatus"/> values to
/// human‑readable description strings. The journal window uses this
/// to select which narrative text to display underneath the quest
/// title as the player advances through <c>NotStarted</c>,
/// <c>Progress</c>, and <c>End</c> states.
/// </summary>
public IDictionary<int, string> StatusToDescription { get; set; } = new Dictionary<int, string>();
/// <summary>
/// Returns <c>true</c> when <paramref name="tag"/> is present in the
/// underlying <see cref="Data"/>.<see cref="QuestData.Tags"/> set.
/// Tags act as boolean flags for sub‑objective completion; dialogue
/// conditions and scene‑transition gates call this to decide whether
/// a particular branch should be available.
/// </summary>
public bool ContainsTag(string tag)
{
return Data.Tags.Contains(tag);
}
/// <summary>
/// Syntactic negation helper that returns <c>true</c> when
/// <paramref name="tag"/> is absent from the quest's tag set.
/// Exists so that scenario scripts can express "tag not yet earned"
/// conditions as a direct method call without writing a negation
/// operator in YAML condition strings.
/// </summary>
public bool NotContainsTag(string tag)
{
return !ContainsTag(tag);
}
/// <summary>
/// Inserts <paramref name="tag"/> into the mutable
/// <see cref="Data"/>.<see cref="QuestData.Tags"/> set, marking a
/// sub‑objective or milestone as completed. Typically called from
/// scenario action nodes or dialogue event handlers when the player
/// reaches a logical checkpoint. Publishes a
/// <see cref="QuestTagChangedEvent"/> only when the tag was actually
/// added (duplicates are a no-op and publish nothing), so condition
/// caches invalidate exactly on real change.
/// </summary>
public void AddTag(string tag)
{
if (Data.Tags.Add(tag))
ModEventBus.Publish(new QuestTagChangedEvent { QuestId = ID, Tag = tag, Added = true });
}
/// <summary>
/// Removes <paramref name="tag"/> from the mutable
/// <see cref="Data"/>.<see cref="QuestData.Tags"/> set, effectively
/// revoking a previously earned milestone. Useful for branching
/// narratives where the player can lose access to a completed
/// sub‑objective through a different choice path. Publishes a
/// <see cref="QuestTagChangedEvent"/> only when the tag was actually
/// present and removed.
/// </summary>
public void RemoveTag(string tag)
{
if (Data.Tags.Remove(tag))
ModEventBus.Publish(new QuestTagChangedEvent { QuestId = ID, Tag = tag, Added = false });
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Events;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Serialisable payload that holds the mutable, per‑save‑file runtime
/// state of a single quest. Separating this from the immutable
/// <see cref="QuestDefinition"/> allows the save system to persist only
/// the dirty data (current status and earned tags) without duplicating
/// the static definition fields that are reloaded from mod JSON every
/// session. An instance of this class lives inside
/// <see cref="QuestBase.Data"/> and is replaced wholesale by
/// <see cref="QuestFactory.SetAll"/> when the player loads a game.
/// </summary>
[Serializable]
public class QuestData
{
/// <summary>
/// Numeric representation of the current quest stage, castable to
/// <see cref="QuestStatus"/>. The journal UI maps this to a
/// description string via <see cref="QuestBase.StatusToDescription"/>.
/// </summary>
public int Status { get; set; }
/// <summary>
/// Set of string tags earned during this playthrough. Each tag
/// represents a branching milestone or sub‑objective; scenario
/// scripts query membership via <see cref="IQuest.ContainsTag"/> to
/// gate dialogue options and scene transitions.
/// </summary>
public HashSet<string> Tags { get; set; } = new();
/// <summary>
/// Sets the numeric status to <paramref name="value"/>, advancing
/// (or potentially resetting) the quest's progression stage. Called
/// by scenario action nodes that handle quest advancement commands.
/// Publishes a <see cref="QuestDataStatusChangedEvent"/> only when the
/// value actually changed, so cached condition results invalidate on
/// real change rather than on every redundant assignment.
/// </summary>
public void SetStatus(int value)
{
if (Status == value)
return;
ModEventBus.Publish(new QuestDataStatusChangedEvent { Old = Status, New = value });
Status = value;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using Cthangover.Core.Factories;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Immutable data template loaded from a mod JSON file whose envelope
/// declares the <c>quest</c> kind. Each definition carries the quest's
/// identity, display name, and a mapping from
/// <see cref="QuestStatus"/> integer values to localised description
/// strings. <see cref="QuestFactory"/> reads these definitions once on
/// initialisation and uses them to seed <see cref="QuestBase"/> runtime
/// instances. The definition itself never changes during gameplay; all
/// mutable progression lives in <see cref="QuestData"/>.
/// Implements <see cref="IIdentifiable"/> so the factory can store
/// quest definitions in a keyed collection.
/// </summary>
[EntityKind("quest")]
public class QuestDefinition : IIdentifiable
{
/// <summary>
/// Unique string key used both as the JSON filename (minus
/// extension) in the mod archive and as the lookup key inside
/// <see cref="QuestFactory"/>'s internal dictionary.
/// </summary>
public string ID { get; set; }
/// <summary>
/// Display name of the quest as written in the mod definition;
/// passed through <c>TranslationServer.Translate</c> before being
/// shown in the journal or notification banners.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Dictionary mapping integer status codes to localised description
/// strings. During initialisation <see cref="QuestFactory"/> copies
/// this dictionary into the corresponding
/// <see cref="QuestBase.StatusToDescription"/> property of the
/// runtime quest instance.
/// </summary>
public Dictionary<int, string> StatusToDescription { get; set; }
}
}
+197
View File
@@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Thread‑safe singleton that manages the entire lifecycle of
/// <see cref="QuestBase"/> instances: lazy initialisation from mod JSON
/// definitions, on‑demand lookup by ID, and restoration of mutable state
/// when the player loads a save file. The factory defers loading until
/// the first access (via <see cref="Get"/> or <see cref="GetAll"/>) and
/// guards against re‑initialisation through the <c>isLoaded</c> flag.
/// Because quest definitions come from <see cref="QuestDefinition"/>
/// objects collected by <see cref="ModManager.GetResolver{EntityResolver}()"/> through the
/// DTO's <c>[EntityKind("quest")]</c> attribute, the factory remains
/// decoupled from any directory layout — new mods register quests simply
/// by shipping a JSON file whose envelope declares the quest kind.
/// <para/>
/// The single instance is process-wide and deliberately not owned by
/// <see cref="AppServices"/>: quest state is identity-sensitive across the
/// whole process (save/restore, scenario conditions), and a swappable owner
/// would break parallel test runs. The new-game reset is wired through the
/// AppServices resettable adapter registered by QuestsModInitializer
/// (see <see cref="ResetAll"/>).
/// </summary>
public class QuestFactory
{
/// <summary>
/// Owns the process-wide instance; laziness defers construction to the
/// first access, avoiding order-dependent initialisation races with
/// other subsystems.
/// </summary>
private static Lazy<QuestFactory> instance = new(() => new QuestFactory());
/// <summary>
/// Process-wide singleton. Deliberately NOT owned by AppServices: quest
/// state is identity-sensitive across the whole process (save/restore,
/// scenario conditions), and a swappable owner breaks that contract in
/// parallel test runs. Reset goes through the AppServices resettable
/// adapter, construction timing stays lazy.
/// </summary>
public static QuestFactory Instance => instance.Value;
private readonly IDictionary<string, QuestBase> dataByID = new Dictionary<string, QuestBase>();
private bool isLoaded;
private QuestFactory()
{
}
/// <summary>
/// Seeds the internal <c>dataByID</c> dictionary from mod JSON if it
/// has not been seeded yet. When <see cref="ModManager"/> is still
/// mid‑initialisation, the call is silently deferred (logged at
/// debug level) so that early code paths don't crash; the next
/// access will retry. Each <see cref="QuestDefinition"/> is
/// converted into a fresh <see cref="QuestBase"/> with default
/// status and an empty tag set.
/// </summary>
private void EnsureLoaded()
{
if (isLoaded)
return;
if (!ModManager.Instance.IsInitialized)
{
GameLogger.Log("QUEST", "mods not initialized, quest loading deferred");
return;
}
isLoaded = true;
try
{
var definitions = Entities.Collect<QuestDefinition>();
if (definitions != null)
{
foreach (var (id, def) in definitions)
{
var quest = new QuestBase
{
ID = def.ID,
Name = def.Name,
StatusToDescription = def.StatusToDescription ?? new Dictionary<int, string>(),
};
dataByID.Add(quest.ID, quest);
}
}
}
catch (Exception ex)
{
GameLogger.Log("QUEST", "failed to load quests from mods: " + ex.Message, LogLevel.Error);
}
GameLogger.Log("QUEST", "loaded '" + dataByID.Count + "' quests");
}
/// <summary>
/// Looks up a quest by its unique string identifier and returns the
/// shared <see cref="QuestBase"/> runtime instance. Throws
/// <c>KeyNotFoundException</c> when no definition for
/// <paramref name="id"/> was loaded from mod data, which typically
/// indicates a typo in a scenario script's quest reference.
/// </summary>
public QuestBase Get(string id)
{
EnsureLoaded();
if(dataByID.TryGetValue(id, out var quest))
return quest;
GameLogger.Log("QUEST", "quest '" + id + "' not registered", LogLevel.Error);
throw new KeyNotFoundException(id);
}
/// <summary>
/// Returns a snapshot list of every <see cref="QuestBase"/> instance
/// known to the factory. Used primarily by the save system to
/// serialise the full quest state for the save file, and by the
/// journal UI to populate the quest list window.
/// </summary>
public List<QuestBase> GetAll()
{
EnsureLoaded();
return dataByID.Values.ToList();
}
/// <summary>
/// Restores mutable quest state from a deserialised save file.
/// Iterates over every quest in the provided list, looks up the
/// corresponding entry by ID, and overwrites its
/// <see cref="QuestBase.Data"/> and <see cref="QuestBase.Status"/>
/// properties. Entries whose ID is not registered (a removed mod or a
/// quest renamed between sessions) are skipped with a warning so that
/// an ageing save cannot block loading. Reads from empty or <c>null</c>
/// lists are silently ignored so that save files created before the
/// quest system existed (or with no quest progress) do not break the
/// pipeline. Publishes a single <see cref="QuestStateRestoredEvent"/>
/// marker after the restore loop (not per-quest) so condition caches
/// invalidate once without a per-quest event storm.
/// </summary>
public void SetAll(List<QuestBase> quests)
{
EnsureLoaded();
if(Lists.IsEmpty(quests))
return;
foreach (var questFromSave in quests)
{
if(!dataByID.TryGetValue(questFromSave.ID, out var quest))
{
GameLogger.Log("QUEST", "quest '" + questFromSave.ID + "' from save is not registered, skipped", LogLevel.Warning);
continue;
}
quest.Data = questFromSave.Data;
quest.Status = questFromSave.Status;
}
ModEventBus.Publish(new QuestStateRestoredEvent());
}
/// <summary>
/// Returns every quest to the state it has right after mod loading:
/// <see cref="QuestStatus.NotStarted"/>, numeric stage zero, and an
/// empty tag set. Called by <c>GameData.StartNewGame</c> because the
/// factory is a process‑wide singleton that survives returning to the
/// main menu — without this reset, tags and statuses earned in the
/// previous playthrough would leak into a new game and satisfy (or
/// block) prologue conditions that test for absent tags. Publishes
/// <see cref="QuestStateRestoredEvent"/> so cached scenario conditions
/// invalidate once.
/// </summary>
public void ResetAll()
{
EnsureLoaded();
foreach (var quest in dataByID.Values)
{
quest.Status = QuestStatus.NotStarted;
quest.Data = new QuestData();
}
ModEventBus.Publish(new QuestStateRestoredEvent());
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.Localization;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Maps quest tags to locale keys and resolves their display text. A bare
/// tag id is only unique inside its quest — the same word may mean
/// different milestones in different quests — so the key is composed from
/// the quest's locale prefix (<see cref="IQuest.Name"/>) and the tag:
/// <c>{quest.Name}/tag/{tag}</c>. For
/// <c>MeetingMurakamiQuest</c> (<c>Name = quest/meeting/murakami</c>) the
/// tag <c>go_home</c> maps to <c>quest/meeting/murakami/tag/go_home</c>,
/// sharing the namespace of the quest's own name and stage descriptions.
/// The journal renders every earned tag as a stamp through
/// <see cref="Translate(IQuest, string)"/>; when no translation exists the
/// raw tag id is shown, which is the behaviour tags had before locale
/// support was added.
/// </summary>
public static class QuestTagLocale
{
/// <summary>
/// Builds the composite locale key for <paramref name="tag"/> inside
/// <paramref name="quest"/>: <c>{quest.Name}/tag/{tag}</c>. Returns
/// <c>null</c> when the quest is missing, has no locale prefix, or the
/// tag is empty, so callers can distinguish "no key to look up" from
/// "key not translated" and fall back to the raw tag.
/// </summary>
public static string Key(IQuest quest, string tag)
{
if (quest == null || string.IsNullOrEmpty(quest.Name) || string.IsNullOrEmpty(tag))
return null;
return quest.Name + "/tag/" + tag;
}
/// <summary>
/// Returns the display text of <paramref name="tag"/> in the player's
/// current language, falling back to the raw tag id when the composite
/// key is absent from all loaded mods' properties files.
/// </summary>
public static string Translate(IQuest quest, string tag)
{
return Translate(quest, tag, GodotTranslationProvider.Instance);
}
/// <summary>
/// Provider-injected variant of <see cref="Translate(IQuest, string)"/>
/// for tests and for callers that already hold an
/// <see cref="ILocalizationProvider"/>. A provider that returns
/// <c>null</c> for the composite key (the "untranslated" signal of
/// <see cref="ILocalizationProvider"/>) also yields the raw tag.
/// </summary>
public static string Translate(IQuest quest, string tag, ILocalizationProvider provider)
{
var key = Key(quest, tag);
if (key == null || provider == null)
return tag;
return provider.Get(key) ?? tag;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using Cthangover.Core.Actions;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes.Parsing;
using Cthangover.Core.Scenes.Parsing.Impls;
using Cthangover.Core.Utils;
namespace Cthangover.Mods.Quests
{
/// <summary>
/// Engine-mod initializer for the quests subsystem. Publishes the services
/// living in this assembly to the kernel contracts as soon as the assembly
/// is registered, so core-side consumers — dialog action contexts
/// (<c>ctx.Quests</c>) and scenario condition evaluation — work through the
/// core registry without referencing a runtime-compiled mod assembly.
/// <para/>
/// Three publications happen here:
/// <list type="bullet">
/// <item><see cref="QuestServiceRegistry.Provider"/> — the
/// <see cref="IQuestService"/> implementation used by
/// <c>ScenarioActionContext</c> and <c>GDQuestService</c>;</item>
/// <item><see cref="LeafParserRegistry.RegisterFirst"/> — the quest leaf
/// parser for scene conditions (<c>QuestId.method(...)</c> /
/// <c>QuestId.Property op n</c>); the parser moved out of the core
/// registry so that the parser set is fully extensible;</item>
/// <item>the new-game resettable (id <c>quests</c>) so
/// <c>GameData.StartNewGame</c> clears quest state through the common
/// AppServices loop (the core used to register this lambda itself).</item>
/// </list>
/// Registration happens in <see cref="OnModLoaded"/> (the earliest and most
/// frequent initializer callback) rather than on resources-ready, so any
/// mod or scenario evaluated later already resolves quests. Registration is
/// guarded and idempotent because <c>ModManager.Reload()</c> re-runs
/// <see cref="OnModLoaded"/> for every mod — the guard prevents duplicate
/// parsers and provider overwrites; the resettable registration is
/// deduplicated by AppServices itself.
/// </summary>
public class QuestsModInitializer : IModInitializer
{
private static bool registered;
/// <summary>Publishes the service provider, leaf parser and reset owner; safe to call again after a mod reload.</summary>
public void OnModLoaded(string modId)
{
if (registered)
return;
registered = true;
QuestServiceRegistry.Provider = new QuestServiceImpl();
LeafParserRegistry.RegisterFirst(new QuestParser());
GameLogger.Log("MODS", "QuestsModInitializer: quest service provider and QuestParser registered");
}
/// <summary>Registers the new-game quest reset owner with AppServices (deduplicated by id).</summary>
public void OnModResourcesReady()
{
AppServices.Current.RegisterResettable(new QuestsResettable());
}
}
/// <summary>
/// New-game state owner of the quests mod: returns every quest to its
/// fresh state (<see cref="QuestFactory.ResetAll"/>). Registered from
/// <see cref="QuestsModInitializer.OnModResourcesReady"/>; AppServices
/// deduplicates by <see cref="Id"/>, so repeated registration is a no-op.
/// </summary>
internal sealed class QuestsResettable : IResettable
{
/// <summary>Resettable key under which AppServices deduplicates registration.</summary>
public string Id => "quests";
/// <summary>Resets all quest statuses, stages and tags on "New Game".</summary>
public void ResetForNewGame() => QuestFactory.Instance.ResetAll();
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
namespace Cthangover.Core.Quests
{
/// <summary>
/// Persists quest state through the generic <see cref="IModSaveable"/>
/// contract under the mod id <c>quests_base</c>. The core save used to
/// carry a <c>"Quests"</c> blob inside the <c>__core__</c> slot
/// (<c>RuntimeSerializer.BuildCoreSlot</c>), but quest state now belongs to
/// the quests engine mod — so it saves through the mod slot like every
/// other migrated subsystem.
/// <para/>
/// <see cref="OnLoad"/> feeds the deserialised list into
/// <see cref="QuestFactory.SetAll"/>, which restores mutable state per
/// quest ID, skips quests that are no longer registered and publishes the
/// single <c>QuestStateRestoredEvent</c> invalidation marker — exactly the
/// behaviour the core save path had. Backward compatibility with the old
/// core blob is intentionally not provided (same decision as the
/// recruiting mod): old saves start with fresh quest state.
/// </summary>
public class QuestsSaveable : IModSaveable
{
private const string BlobKey = "Quests";
/// <summary>Writes the full quest state list into the mod slot.</summary>
public void OnSave(ModSlot slot)
{
slot.Data[BlobKey] = new DataBlob(1, QuestFactory.Instance.GetAll());
}
/// <summary>Restores quest state from the mod slot; an absent blob keeps fresh quest state.</summary>
public bool OnLoad(ModSlot slot, out string errorMessage)
{
errorMessage = null;
if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob))
QuestFactory.Instance.SetAll(blob.ReadJson<List<QuestBase>>());
return true;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using System;
namespace Cthangover.Core.Scenes.Parsing.Impls
{
/// <summary>
/// Parses quest expressions: <c>QuestId.method("arg")</c> and
/// <c>QuestId.Property comparison</c>.
///
/// Matches when the stream contains an identifier followed by a dot
/// that is NOT <c>true.</c> or <c>false.</c> (those are quest IDs,
/// not boolean literals followed by quest access — handled here because
/// <see cref="BoolLiteralParser"/> already rejected them).
/// </summary>
public class QuestParser : ILeafParser
{
/// <summary>True for identifiers followed by a dot (dot-forms that survived the boolean-literal check are quest references).</summary>
public bool CanParse(TokenPeeker peeker)
{
if (peeker.AtEnd || peeker.Peek.Type != ConditionParser.TokenType.Ident)
return false;
var next = peeker.PeekAhead(1);
return next.Type == ConditionParser.TokenType.Dot;
}
/// <summary>Dispatches on the member after the dot: <c>hasTag</c>/<c>notHasTag</c>, <c>IsActive</c>, or <c>Status</c>/<c>DataStatus</c> comparisons; unknown members throw.</summary>
public ICondition Parse(TokenPeeker peeker, Func<ICondition> parseSubExpr)
{
var questId = peeker.Advance().Value;
peeker.Expect(ConditionParser.TokenType.Dot, "expected '.' after quest id");
var methodToken = peeker.Expect(ConditionParser.TokenType.Ident, "expected method or property name after '.'");
var method = methodToken.Value;
if (method == "hasTag" || method == "notHasTag")
{
peeker.Expect(ConditionParser.TokenType.LParen, "expected '('");
var tag = peeker.Expect(ConditionParser.TokenType.String, "expected string argument");
peeker.Expect(ConditionParser.TokenType.RParen, "expected ')'");
return new QuestMethodCondition(questId, method, tag.Value);
}
if (method == "IsActive")
{
return new QuestBoolPropCondition(questId, method);
}
if (method == "Status" || method == "DataStatus")
{
var op = ParseCompOp(peeker);
var num = peeker.Expect(ConditionParser.TokenType.Number, "expected number after comparison");
return new QuestPropCondition(questId, method, op, int.Parse(num.Value));
}
throw new FormatException($"Condition parse error: unknown quest property/method '{method}'");
}
private static string ParseCompOp(TokenPeeker peeker)
{
if (peeker.Check(ConditionParser.TokenType.Eq)) { peeker.Advance(); return "=="; }
if (peeker.Check(ConditionParser.TokenType.Neq)) { peeker.Advance(); return "!="; }
if (peeker.Check(ConditionParser.TokenType.Ge)) { peeker.Advance(); return ">="; }
if (peeker.Check(ConditionParser.TokenType.Le)) { peeker.Advance(); return "<="; }
if (peeker.Check(ConditionParser.TokenType.Gt)) { peeker.Advance(); return ">"; }
if (peeker.Check(ConditionParser.TokenType.Lt)) { peeker.Advance(); return "<"; }
throw new FormatException($"Condition parse error: expected comparison operator, got '{peeker.Peek.Value}'");
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using Cthangover.Core.Quests;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Scenes
{
/// <summary>
/// An AST leaf node that checks a boolean property on a quest instance.
/// Currently the only supported property is <c>IsActive</c>, which returns
/// <c>true</c> when the quest status equals <see cref="QuestStatus.Progress"/>.
/// Fetches the quest via <see cref="EvaluationContext.QuestFactory"/> and
/// returns <c>false</c> (with a log warning) when the quest ID is not found.
/// </summary>
public class QuestBoolPropCondition : ConditionNode
{
/// <summary>The quest identifier to query.</summary>
public string QuestId { get; }
/// <summary>The property name: currently only <c>"IsActive"</c>.</summary>
public string Property { get; }
/// <summary>Creates a boolean quest-property condition node.</summary>
/// <param name="questId">The quest identifier.</param>
/// <param name="property">The property name (currently only <c>"IsActive"</c>).</param>
public QuestBoolPropCondition(string questId, string property)
{
QuestId = questId;
Property = property;
}
/// <summary>Resolves the quest and reads the boolean property; unknown quest IDs evaluate to <c>false</c>.</summary>
public override bool Evaluate(EvaluationContext ctx)
{
try
{
var quest = ctx.QuestFactory.Get(QuestId);
if (Property == "IsActive")
return quest.Status == QuestStatus.Progress;
return false;
}
catch (KeyNotFoundException)
{
GameLogger.Log("COND", $"quest '{QuestId}' not found, condition → false", LogLevel.Error);
return false;
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Scenes
{
/// <summary>
/// An AST leaf node invoking a boolean method on a quest instance: either
/// <c>hasTag</c> or <c>notHasTag</c>. Fetches the quest via
/// <see cref="EvaluationContext.QuestFactory"/> at evaluation time and calls
/// the corresponding method with the given tag string. Returns <c>false</c>
/// (with a log warning) when the quest ID is not found in the factory.
/// </summary>
public class QuestMethodCondition : ConditionNode
{
/// <summary>The quest identifier to query.</summary>
public string QuestId { get; }
/// <summary>The method name: <c>"hasTag"</c> or <c>"notHasTag"</c>.</summary>
public string Method { get; }
/// <summary>The tag string passed as the method argument.</summary>
public string Tag { get; }
/// <summary>
/// Creates a quest method invocation condition node.
/// </summary>
/// <param name="questId">The quest identifier.</param>
/// <param name="method">The method name (<c>"hasTag"</c> or <c>"notHasTag"</c>).</param>
/// <param name="tag">The tag argument for the method call.</param>
public QuestMethodCondition(string questId, string method, string tag)
{
QuestId = questId;
Method = method;
Tag = tag;
}
/// <summary>Resolves the quest and invokes the tag check; unknown quest IDs evaluate to <c>false</c>.</summary>
public override bool Evaluate(EvaluationContext ctx)
{
try
{
var quest = ctx.QuestFactory.Get(QuestId);
return Method == "hasTag"
? quest.ContainsTag(Tag)
: !quest.ContainsTag(Tag);
}
catch (KeyNotFoundException)
{
GameLogger.Log("COND", $"quest '{QuestId}' not found, condition → false", LogLevel.Error);
return false;
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using Cthangover.Core.Quests;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Scenes
{
/// <summary>
/// An AST leaf node comparing a quest's integer property (<c>Status</c> or
/// <c>DataStatus</c>) against a numeric value. Fetches the quest via
/// <see cref="EvaluationContext.QuestFactory"/> at evaluation time, reads the
/// corresponding property, and compares using <see cref="ConditionCompare.Compare"/>.
/// Returns <c>false</c> (with a log warning) when the quest ID is not found.
/// </summary>
public class QuestPropCondition : ConditionNode
{
/// <summary>The quest identifier to query.</summary>
public string QuestId { get; }
/// <summary>The property name: <c>"Status"</c> or <c>"DataStatus"</c>.</summary>
public string Property { get; }
/// <summary>The comparison operator (<c>==</c>, <c>!=</c>, <c>&gt;=</c>, etc.).</summary>
public string Op { get; }
/// <summary>The integer value to compare the quest property against.</summary>
public int Value { get; }
/// <summary>
/// Creates a quest property comparison condition node.
/// </summary>
/// <param name="questId">The quest identifier.</param>
/// <param name="property">The property name (<c>"Status"</c> or <c>"DataStatus"</c>).</param>
/// <param name="op">The comparison operator.</param>
/// <param name="value">The integer value to compare against.</param>
public QuestPropCondition(string questId, string property, string op, int value)
{
QuestId = questId;
Property = property;
Op = op;
Value = value;
}
/// <summary>Resolves the quest and compares the numeric property; unknown quest IDs evaluate to <c>false</c>.</summary>
public override bool Evaluate(EvaluationContext ctx)
{
try
{
var quest = ctx.QuestFactory.Get(QuestId);
var actual = Property == "Status"
? (int)quest.Status
: ((QuestBase)quest).Data.Status;
return ConditionCompare.Compare(Op, actual, Value);
}
catch (KeyNotFoundException)
{
GameLogger.Log("COND", $"quest '{QuestId}' not found, condition → false", LogLevel.Error);
return false;
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Cthangover.Mods.Quests
{
/// <summary>
/// Migration marker scanned by the integration coverage test
/// (<c>SubsystemsBridge</c>): declares the quests subsystem as
/// migrated out of the core assembly into this engine mod. The
/// subsystem id must match the entry in
/// <c>SubsystemsCoverageTestSetup.DeclaredTests</c> and
/// <c>tests/tests.manifest.json</c>.
/// </summary>
public static class SubsystemRegistration
{
/// <summary>Subsystem id reported to the coverage test.</summary>
public static string SubsystemId => "quests";
}
}
+627
View File
@@ -0,0 +1,627 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Cthangover.Core.Quests;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Quests
{
/// <summary>
/// Quest journal widget styled as an open folio: a two-page spread on a dark
/// backdrop, with filter tabs tucked into the top of the left page. The left page is a
/// table of contents listing quests filtered by active
/// (<see cref="QuestStatus.Progress"/>) or completed (<see cref="QuestStatus.End"/>)
/// state; <see cref="QuestStatus.NotStarted"/> quests are never shown. The right
/// page renders the selected quest as a journal entry: entries with index less than
/// the current <see cref="QuestData.Status"/> are struck through, the entry at the
/// current status is highlighted, and entries beyond it are hidden. For completed
/// quests every entry is struck through. Earned <see cref="QuestData.Tags"/> are
/// displayed as gold tags under the entry. All decoration (surface gradients,
/// inner borders, spine shading, tags) is generated in code because mod scenes
/// ignore theme overrides and the project ships no font or texture assets.
/// The palette is the shared graphite/gold one (<see cref="UiTokens"/>) so the
/// journal does not stand out from the inventory and character panels.
/// </summary>
public partial class QuestJournalBehaviour : Widget
{
private enum FilterType
{
Active,
Completed
}
private static readonly Color InkColor = UiTokens.Ink;
private static readonly Color InkBrightColor = UiTokens.InkBright;
private static readonly Color InkDimColor = UiTokens.Muted;
private static readonly Color InkFaintColor = UiTokens.Faint;
private static readonly Color PageTopColor = UiTokens.Surface1;
private static readonly Color PageColor = UiTokens.Surface0;
private static readonly Color PageLineColor = UiTokens.LineSoft;
private static readonly Color RuleColor = UiTokens.LineFaint;
private static readonly Color CoverLineColor = UiTokens.Line;
private static readonly Color GoldColor = UiTokens.Accent;
private static readonly Color GoldGlowColor = UiTokens.AccentGlow;
private static readonly Color GoldTraceColor = UiTokens.AccentFaint;
private static readonly Color SealColor = UiTokens.AccentLine;
private static readonly Color SealTextColor = UiTokens.AccentBright;
private static readonly Color SealFillColor = UiTokens.AccentSoft;
private const int FilterTabsHeight = 34;
private const int FilterTabsGap = 14;
private FilterType _currentFilter = FilterType.Active;
private string _selectedQuestId;
private VBoxContainer _questListContainer;
private RichTextLabel _entryText;
private Label _questNameLabel;
private Label _stageLabel;
private Label _emptyLabel;
private MarginContainer _sealsBlock;
private HFlowContainer _sealsContainer;
private Button _activeFilterBtn;
private Button _completedFilterBtn;
// Scene nodes (quest_journal.tscn): the folio layout lives in the scene,
// the behaviour resolves it and applies gradient textures, styles and
// the dynamic quest data.
private Label _title;
private Button _closeButton;
private Label _tocHeader;
private Label _entryHeader;
private Label _sealsHeader;
private TextureRect _leftPageGradient;
private TextureRect _spineShade;
private TextureRect _rightPageGradient;
private Panel _diamond;
private TextureRect _dividerLineL;
private TextureRect _dividerLineR;
protected override void OnceConstruct()
{
MouseFilter = MouseFilterEnum.Stop;
_title = GetNode<Label>("Title");
_closeButton = GetNode<Button>("CloseButton");
_activeFilterBtn = GetNode<Button>("BookArea/Book/LeftPage/Content/Column/FilterTabs/ActiveFilterBtn");
_completedFilterBtn = GetNode<Button>("BookArea/Book/LeftPage/Content/Column/FilterTabs/CompletedFilterBtn");
_tocHeader = GetNode<Label>("BookArea/Book/LeftPage/Content/Column/TocHeader");
_emptyLabel = GetNode<Label>("BookArea/Book/LeftPage/Content/Column/EmptyLabel");
_questListContainer = GetNode<VBoxContainer>("BookArea/Book/LeftPage/Content/Column/Scroll/QuestListContainer");
_entryHeader = GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/EntryHeader");
_questNameLabel = GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/NameMargin/QuestNameLabel");
_stageLabel = GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/StageLabel");
_entryText = GetNode<RichTextLabel>("BookArea/Book/RightPage/Content/Scroll/Column/EntryText");
_sealsBlock = GetNode<MarginContainer>("BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock");
_sealsContainer = GetNode<HFlowContainer>("BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column/SealsContainer");
_sealsHeader = GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column/SealsHeader");
_leftPageGradient = GetNode<TextureRect>("BookArea/Book/LeftPage/PageGradient");
_spineShade = GetNode<TextureRect>("BookArea/Book/Spine/SpineShade");
_rightPageGradient = GetNode<TextureRect>("BookArea/Book/RightPage/PageGradient");
_diamond = GetNode<Panel>("BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row/Diamond");
_dividerLineL = GetNode<TextureRect>("BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row/LineL");
_dividerLineR = GetNode<TextureRect>("BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row/LineR");
ApplyTheme();
ApplyCaptions();
_activeFilterBtn.Pressed += () => ApplyFilter(FilterType.Active);
_completedFilterBtn.Pressed += () => ApplyFilter(FilterType.Completed);
_closeButton.Pressed += () => Hide();
}
/// <summary>Gradient textures, page styles and decorations for scene nodes (texture assets ship no assets, so gradients are generated here).</summary>
private void ApplyTheme()
{
UiKit.ApplyPanelBackdrop(GetNode<ColorRect>("Bg"));
GetNode<Panel>("BookArea/Book/LeftPage").AddThemeStyleboxOverride("panel", MakePageStyle(true));
GetNode<Panel>("BookArea/Book/RightPage").AddThemeStyleboxOverride("panel", MakePageStyle(false));
GetNode<Panel>("BookArea/Book/Spine").AddThemeStyleboxOverride("panel", MakeSpineStyle());
GetNode<Panel>("BookArea/Book/LeftPage/InnerBorder").AddThemeStyleboxOverride("panel", MakeInnerBorderStyle());
GetNode<Panel>("BookArea/Book/RightPage/InnerBorder").AddThemeStyleboxOverride("panel", MakeInnerBorderStyle());
_leftPageGradient.Texture = MakePageGradientTexture();
_rightPageGradient.Texture = MakePageGradientTexture();
_spineShade.Texture = MakeSpineShadeTexture();
_title.AddThemeFontSizeOverride("font_size", UiTokens.FontTitle);
_title.AddThemeColorOverride("font_color", UiTokens.InkBright);
_tocHeader.Text = TranslateUpper("ui/journal/toc");
_entryHeader.Text = TranslateUpper("ui/journal/entry_header");
_sealsHeader.Text = TranslateUpper("ui/journal/seals");
foreach (var header in new[] { _tocHeader, _entryHeader, _sealsHeader })
{
header.AddThemeFontSizeOverride("font_size", 14);
header.AddThemeColorOverride("font_color", UiTokens.AccentDim);
}
_questNameLabel.AddThemeColorOverride("font_color", InkBrightColor);
_entryText.AddThemeFontSizeOverride("normal_font_size", 16);
_entryText.AddThemeConstantOverride("line_separation", 7);
_entryText.AddThemeColorOverride("default_color", InkColor);
_emptyLabel.AddThemeColorOverride("font_color", InkFaintColor);
StyleFilterTab(_activeFilterBtn, _currentFilter == FilterType.Active);
StyleFilterTab(_completedFilterBtn, _currentFilter == FilterType.Completed);
_diamond.AddThemeStyleboxOverride("panel", MakeSolidStyle(GoldColor));
_dividerLineL.Texture = MakeFadingLineTexture(false);
_dividerLineR.Texture = MakeFadingLineTexture(true);
CthangoverTheme.ApplyIcon(_closeButton);
}
/// <summary>Localized captions for all static labels of the scene.</summary>
private void ApplyCaptions()
{
_title.Text = TranslateUpper("ui/journal/title");
_closeButton.TooltipText = TranslationServer.Translate("ui/journal/close");
_activeFilterBtn.Text = TranslationServer.Translate("ui/journal/active");
_completedFilterBtn.Text = TranslationServer.Translate("ui/journal/completed");
_emptyLabel.Text = TranslationServer.Translate("ui/journal/empty");
}
/// <summary>Translation-safe upper-casing: a missing key yields an empty string instead of dereferencing a null StringName.</summary>
private static string TranslateUpper(string key)
{
var translated = TranslationServer.Translate(key)?.ToString();
return string.IsNullOrEmpty(translated) ? string.Empty : translated.ToUpperInvariant();
}
private static StyleBoxFlat MakeSolidStyle(Color color)
{
var style = new StyleBoxFlat();
style.BgColor = color;
return style;
}
private static StyleBoxFlat MakeSpineStyle()
{
var style = new StyleBoxFlat();
style.BgColor = PageColor;
style.BorderColor = CoverLineColor;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
return style;
}
private static StyleBoxFlat MakeInnerBorderStyle()
{
var style = new StyleBoxFlat();
style.BgColor = UiTokens.Alpha(Colors.Black, 0f);
style.BorderColor = PageLineColor;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.CornerRadiusTopLeft = 3;
style.CornerRadiusTopRight = 3;
style.CornerRadiusBottomLeft = 3;
style.CornerRadiusBottomRight = 3;
return style;
}
private static GradientTexture2D MakePageGradientTexture()
{
var gradient = new Gradient();
gradient.SetColor(0, PageTopColor);
gradient.SetColor(1, PageColor);
var texture = new GradientTexture2D();
texture.Gradient = gradient;
texture.Width = 8;
texture.Height = 256;
texture.FillFrom = new Vector2(0.5f, 0f);
texture.FillTo = new Vector2(0.5f, 1f);
return texture;
}
private static GradientTexture2D MakeSpineShadeTexture()
{
var gradient = new Gradient();
gradient.SetColor(0, UiTokens.Alpha(Colors.Black, 0f));
gradient.SetOffset(0, 0f);
gradient.SetColor(1, UiTokens.Alpha(Colors.Black, 0f));
gradient.SetOffset(1, 1f);
gradient.AddPoint(0.5f, UiTokens.Alpha(Colors.Black, 0.62f));
var texture = new GradientTexture2D();
texture.Gradient = gradient;
texture.Width = 128;
texture.Height = 4;
texture.FillFrom = new Vector2(0f, 0.5f);
texture.FillTo = new Vector2(1f, 0.5f);
return texture;
}
private static GradientTexture2D MakeFadingLineTexture(bool fadeToRight)
{
var gradient = new Gradient();
gradient.SetColor(0, fadeToRight ? RuleColor : UiTokens.Alpha(Colors.Black, 0f));
gradient.SetColor(1, fadeToRight ? UiTokens.Alpha(Colors.Black, 0f) : RuleColor);
var texture = new GradientTexture2D();
texture.Gradient = gradient;
texture.Width = 256;
texture.Height = 1;
texture.FillFrom = new Vector2(0f, 0.5f);
texture.FillTo = new Vector2(1f, 0.5f);
return texture;
}
protected override void ShowConstruct()
{
_selectedQuestId = null;
ApplyFilter(_currentFilter);
}
protected override void HideDestruct()
{
ClearChildren(_questListContainer);
ClearChildren(_sealsContainer);
_selectedQuestId = null;
}
private static void StyleFilterTab(Button button, bool active)
{
button.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
button.AddThemeStyleboxOverride("normal", MakeFilterTabStyle(active, false));
button.AddThemeStyleboxOverride("hover", MakeFilterTabStyle(active, true));
button.AddThemeStyleboxOverride("pressed", MakeFilterTabStyle(active, true));
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
button.AddThemeColorOverride("font_color", active ? UiTokens.AccentBright : UiTokens.Muted);
button.AddThemeColorOverride("font_hover_color", active ? UiTokens.AccentBright : UiTokens.Ink);
button.AddThemeColorOverride("font_pressed_color", active ? UiTokens.AccentBright : UiTokens.Ink);
}
private static StyleBoxFlat MakeFilterTabStyle(bool active, bool hover)
{
var style = new StyleBoxFlat();
style.BgColor = active ? UiTokens.AccentSoft : (hover ? UiTokens.WhiteTrace : Colors.Transparent);
style.BorderColor = active ? UiTokens.AccentLine : Colors.Transparent;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.CornerRadiusTopLeft = UiTokens.RadiusSm;
style.CornerRadiusTopRight = UiTokens.RadiusSm;
style.CornerRadiusBottomLeft = UiTokens.RadiusSm;
style.CornerRadiusBottomRight = UiTokens.RadiusSm;
style.ContentMarginLeft = 13;
style.ContentMarginRight = 13;
style.ContentMarginTop = 7;
style.ContentMarginBottom = 7;
return style;
}
private static StyleBoxFlat MakePageStyle(bool left)
{
var style = new StyleBoxFlat();
style.BgColor = PageColor;
style.BorderColor = CoverLineColor;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.ShadowColor = UiTokens.Alpha(Colors.Black, 0.85f);
style.ShadowSize = 24;
style.ShadowOffset = new Vector2(0f, 10f);
if (left)
{
style.CornerRadiusTopLeft = 6;
style.CornerRadiusBottomLeft = 6;
}
else
{
style.CornerRadiusTopRight = 6;
style.CornerRadiusBottomRight = 6;
}
return style;
}
private void ApplyFilter(FilterType filter)
{
_currentFilter = filter;
StyleFilterTab(_activeFilterBtn, filter == FilterType.Active);
StyleFilterTab(_completedFilterBtn, filter == FilterType.Completed);
ClearChildren(_questListContainer);
var allQuests = QuestFactory.Instance.GetAll();
List<QuestBase> filtered = null;
if (allQuests != null && allQuests.Count > 0)
{
if (filter == FilterType.Active)
filtered = allQuests.Where(q => q.Status == QuestStatus.Progress).ToList();
else
filtered = allQuests.Where(q => q.Status == QuestStatus.End).ToList();
}
if (filtered == null || filtered.Count == 0)
{
_emptyLabel.Visible = true;
_selectedQuestId = null;
ShowPlaceholder();
return;
}
_emptyLabel.Visible = false;
if (!filtered.Any(q => q.ID == _selectedQuestId))
_selectedQuestId = filtered[0].ID;
foreach (var quest in filtered)
_questListContainer.AddChild(BuildQuestItem(quest));
BuildDetailPanel(filtered.First(q => q.ID == _selectedQuestId));
}
private Button BuildQuestItem(QuestBase quest)
{
var selected = quest.ID == _selectedQuestId;
var button = new Button();
button.FocusMode = FocusModeEnum.None;
button.CustomMinimumSize = new Vector2(0, 46);
button.SizeFlagsHorizontal = SizeFlags.ExpandFill;
button.AddThemeStyleboxOverride("normal", MakeItemStyle(selected, false));
button.AddThemeStyleboxOverride("hover", MakeItemStyle(selected, true));
button.AddThemeStyleboxOverride("pressed", MakeItemStyle(selected, true));
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
button.Pressed += () => OnQuestClicked(quest);
var row = new HBoxContainer();
row.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
row.OffsetLeft = 14;
row.OffsetRight = -12;
row.AddThemeConstantOverride("separation", 10);
row.MouseFilter = MouseFilterEnum.Ignore;
button.AddChild(row);
var marker = MakeDiamond(quest.Status == QuestStatus.End ? InkFaintColor : GoldColor, 9);
row.AddChild(marker);
var name = new Label();
name.Text = TranslationServer.Translate(quest.Name);
name.AddThemeFontSizeOverride("font_size", 17);
name.AddThemeColorOverride("font_color", selected ? InkBrightColor : (quest.Status == QuestStatus.End ? InkDimColor : InkColor));
name.TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis;
name.ClipText = true;
name.VerticalAlignment = VerticalAlignment.Center;
name.SizeFlagsHorizontal = SizeFlags.ExpandFill;
name.SizeFlagsStretchRatio = 2f;
name.SizeFlagsVertical = SizeFlags.ShrinkCenter;
name.MouseFilter = MouseFilterEnum.Ignore;
row.AddChild(name);
row.AddChild(MakeFadingLine(true));
var stage = new Label();
stage.Text = quest.Status == QuestStatus.End
? TranslationServer.Translate("ui/journal/finished")
: (quest.StatusToDescription != null && quest.StatusToDescription.Count > 0
? ToRoman(Math.Min(quest.Data.Status + 1, quest.StatusToDescription.Count))
: "—");
stage.AddThemeFontSizeOverride("font_size", 14);
stage.AddThemeColorOverride("font_color", quest.Status == QuestStatus.End ? InkFaintColor : UiTokens.AccentDim);
stage.VerticalAlignment = VerticalAlignment.Center;
stage.SizeFlagsVertical = SizeFlags.ShrinkCenter;
stage.MouseFilter = MouseFilterEnum.Ignore;
row.AddChild(stage);
return button;
}
private static StyleBoxFlat MakeItemStyle(bool selected, bool hover)
{
var style = new StyleBoxFlat();
style.BgColor = selected ? GoldGlowColor : (hover ? GoldTraceColor : UiTokens.Alpha(Colors.Black, 0f));
style.ContentMarginLeft = 2;
style.ContentMarginRight = 2;
style.CornerRadiusTopRight = 4;
style.CornerRadiusBottomRight = 4;
if (selected)
{
style.BorderColor = GoldColor;
style.BorderWidthLeft = 2;
}
return style;
}
private void OnQuestClicked(QuestBase quest)
{
_selectedQuestId = quest.ID;
ApplyFilter(_currentFilter);
}
private void BuildDetailPanel(QuestBase quest)
{
_questNameLabel.Text = TranslationServer.Translate(quest.Name);
var descriptions = quest.StatusToDescription;
var hasEntries = descriptions != null && descriptions.Count > 0;
var isClosed = quest.Status == QuestStatus.End;
var currentStatus = quest.Data.Status;
if (isClosed)
{
_stageLabel.Text = TranslationServer.Translate("ui/journal/finished");
_stageLabel.AddThemeColorOverride("font_color", InkDimColor);
}
else if (hasEntries)
{
_stageLabel.Text = string.Format(
TranslationServer.Translate("ui/journal/entry_of"),
ToRoman(Math.Min(currentStatus + 1, descriptions.Count)),
ToRoman(descriptions.Count));
_stageLabel.AddThemeColorOverride("font_color", GoldColor);
}
else
{
_stageLabel.Text = "";
}
BuildEntryText(descriptions, hasEntries, isClosed, currentStatus);
BuildSeals(quest);
}
private void BuildEntryText(IDictionary<int, string> descriptions, bool hasEntries, bool isClosed, int currentStatus)
{
if (!hasEntries)
{
_entryText.Text = "[i][color=#" + UiTokens.Faint.ToHtml(false) + "]" +
TranslationServer.Translate("ui/journal/no_entries") + "[/color][/i]";
return;
}
var builder = new StringBuilder();
foreach (var key in descriptions.Keys.OrderBy(k => k))
{
if (key > currentStatus)
continue;
var text = TranslationServer.Translate(descriptions[key]);
if (isClosed || key < currentStatus)
{
builder.Append("[color=#").Append(UiTokens.Faint.ToHtml(false)).Append("][s]")
.Append(text).Append("[/s][/color]");
}
else
{
builder.Append("[color=#").Append(UiTokens.Accent.ToHtml(false)).Append("]• [/color]")
.Append("[color=#").Append(UiTokens.InkBright.ToHtml(false)).Append("]")
.Append(text).Append("[/color]");
}
builder.Append("\n\n");
}
_entryText.Text = builder.ToString();
}
private void BuildSeals(QuestBase quest)
{
ClearChildren(_sealsContainer);
var tags = quest.Data?.Tags;
_sealsBlock.Visible = tags != null && tags.Count > 0;
if (tags == null || tags.Count == 0)
return;
foreach (var tag in tags.OrderBy(t => t))
_sealsContainer.AddChild(MakeSeal(quest, tag));
}
private static Control MakeSeal(QuestBase quest, string tag)
{
var seal = new PanelContainer();
var style = new StyleBoxFlat();
style.BgColor = SealFillColor;
style.BorderColor = SealColor;
style.BorderWidthLeft = 1;
style.BorderWidthRight = 1;
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.ContentMarginLeft = 12;
style.ContentMarginRight = 12;
style.ContentMarginTop = 5;
style.ContentMarginBottom = 5;
style.CornerRadiusTopLeft = 4;
style.CornerRadiusTopRight = 4;
style.CornerRadiusBottomLeft = 4;
style.CornerRadiusBottomRight = 4;
seal.AddThemeStyleboxOverride("panel", style);
seal.PivotOffset = Vector2.Zero;
seal.Rotation = -0.0436f;
seal.Resized += () => seal.PivotOffset = seal.Size / 2f;
var label = new Label();
label.Text = QuestTagLocale.Translate(quest, tag);
label.AddThemeFontSizeOverride("font_size", 13);
label.AddThemeColorOverride("font_color", SealTextColor);
label.MouseFilter = MouseFilterEnum.Ignore;
seal.AddChild(label);
return seal;
}
private void ShowPlaceholder()
{
_questNameLabel.Text = "";
_stageLabel.Text = "";
_entryText.Text = "[i][color=#" + UiTokens.Faint.ToHtml(false) + "]" +
TranslationServer.Translate("ui/journal/empty") + "[/color][/i]";
_sealsBlock.Visible = false;
}
private static void ClearChildren(Node container)
{
foreach (var child in container.GetChildren())
{
container.RemoveChild(child);
child.QueueFree();
}
}
private static Panel MakeDiamond(Color color, int size)
{
var diamond = new Panel();
diamond.CustomMinimumSize = new Vector2(size, size);
diamond.SizeFlagsVertical = SizeFlags.ShrinkCenter;
diamond.PivotOffset = new Vector2(size / 2f, size / 2f);
diamond.Rotation = 0.7854f;
var style = new StyleBoxFlat();
style.BgColor = color;
diamond.AddThemeStyleboxOverride("panel", style);
diamond.MouseFilter = MouseFilterEnum.Ignore;
return diamond;
}
private static TextureRect MakeFadingLine(bool fadeToRight)
{
var rect = new TextureRect();
rect.Texture = MakeFadingLineTexture(fadeToRight);
rect.CustomMinimumSize = new Vector2(30, 1);
rect.SizeFlagsHorizontal = SizeFlags.ExpandFill;
rect.SizeFlagsVertical = SizeFlags.ShrinkCenter;
rect.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
rect.StretchMode = TextureRect.StretchModeEnum.Scale;
rect.MouseFilter = MouseFilterEnum.Ignore;
return rect;
}
private static string ToRoman(int value)
{
if (value <= 0)
return "—";
int[] values = { 50, 40, 10, 9, 5, 4, 1 };
string[] symbols = { "L", "XL", "X", "IX", "V", "IV", "I" };
var builder = new StringBuilder();
for (var i = 0; i < values.Length; i++)
{
while (value >= values[i])
{
builder.Append(symbols[i]);
value -= values[i];
}
}
return builder.ToString();
}
}
}