init mod
This commit is contained in:
commit
b9b508a1d0
24 files changed
+2201
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
mod_quests_base_name = Движок квестов
|
||||
mod_quests_base_desc = Подсистема квестов движка: фабрика квестов и их состояние, реализация IQuestService, сценарные действия, условия квестов в сценах и журнал квестов.
|
||||
ui/journal/new = Новая запись в журнале
|
||||
ui/journal/updated = Задание обновлено
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<QuestStatus>.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<QuestStatus>.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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<QuestStatus>.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>=</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user