├── .editorconfig ├── .gitattributes ├── .gitignore ├── DotnetQuestSystemAddon.csproj ├── DotnetQuestSystemAddon.sln ├── LICENSE ├── README.md ├── addons └── dotnetquestsystem │ ├── MainPage.cs │ ├── MainPage.tscn │ ├── Plugin.cs │ ├── RewardOption.cs │ ├── api │ ├── Quest.cs │ ├── QuestController.cs │ ├── QuestDatabase.cs │ ├── QuestManager.cs │ ├── QuestStatus.cs │ ├── QuestSystemAPI.csproj │ ├── Reward │ │ ├── ExperienceReward.cs │ │ ├── IReward.cs │ │ └── ItemReward.cs │ └── Save │ │ ├── ISaveSystemStrategy.cs │ │ ├── QuestDatabaseSave.cs │ │ └── QuestLocalSave.cs │ ├── assets │ ├── dotnetquestsystem_node.svg │ └── godot_asset_icon.png │ └── plugin.cfg ├── docs ├── api │ ├── quest.md │ ├── quest_controller.md │ ├── quest_database.md │ ├── quest_manager.md │ ├── quest_status.md │ ├── reward.md │ └── save_system.md ├── contributing.md └── getting_started.md ├── project.godot └── test └── QuestSystemAPITest ├── QuestControllerTest.cs ├── QuestControllerTest.cs.uid ├── QuestDatabaseTest.cs ├── QuestDatabaseTest.cs.uid ├── QuestManagerTest.cs ├── QuestManagerTest.cs.uid ├── QuestSystemAPITest.csproj ├── QuestTest.cs └── QuestTest.cs.uid /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize EOL for all files that Git considers text files. 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | /android/ 4 | .import/ 5 | .mono/ 6 | 7 | # Ignore standard build folders created during project compilation 8 | bin/ 9 | obj/ 10 | Debug/ 11 | Release/ 12 | 13 | # Ignore Visual Studio Code settings and configurations 14 | .vscode/ 15 | 16 | # Ignore temporary files that may be created during development 17 | tmp/ 18 | *.tmp 19 | *.temp -------------------------------------------------------------------------------- /DotnetQuestSystemAddon.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | true 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | $(DefaultItemExcludes);test\**\* 12 | 13 | 14 | -------------------------------------------------------------------------------- /DotnetQuestSystemAddon.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.13.35913.81 d17.13 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetQuestSystemAddon", "DotnetQuestSystemAddon.csproj", "{37491199-0456-4B57-9CD5-DE2BBC4CAD5C}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | ExportDebug|Any CPU = ExportDebug|Any CPU 12 | ExportRelease|Any CPU = ExportRelease|Any CPU 13 | EndGlobalSection 14 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 15 | {37491199-0456-4B57-9CD5-DE2BBC4CAD5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 16 | {37491199-0456-4B57-9CD5-DE2BBC4CAD5C}.Debug|Any CPU.Build.0 = Debug|Any CPU 17 | {37491199-0456-4B57-9CD5-DE2BBC4CAD5C}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU 18 | {37491199-0456-4B57-9CD5-DE2BBC4CAD5C}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU 19 | {37491199-0456-4B57-9CD5-DE2BBC4CAD5C}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU 20 | {37491199-0456-4B57-9CD5-DE2BBC4CAD5C}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU 21 | EndGlobalSection 22 | GlobalSection(SolutionProperties) = preSolution 23 | HideSolutionNode = FALSE 24 | EndGlobalSection 25 | GlobalSection(ExtensibilityGlobals) = postSolution 26 | SolutionGuid = {8D3A86B4-78EB-495D-A152-9246578A1F87} 27 | EndGlobalSection 28 | EndGlobal 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 TRUINGLol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

DOTNET QUEST SYSTEM

2 | 3 | [![GodotEngine](https://img.shields.io/badge/-Godot%204-250a78?style=flat&logo=godotengine&logoColor=white&labelColor=250a78)](https://godotengine.org/) 4 | [![Awesome](https://awesome.re/badge.svg)](https://github.com/godotengine/awesome-godot) 5 | ![Version](https://img.shields.io/badge/version-1.1.0-brightgreen.svg) 6 | 7 | # Simple quest system for Godot 8 | DotnetQuestSystem - is a simple and powerful [Godot](https://godotengine.org/) add-on that greatly speeds up the process of creating a quest system. Its clean API, written in C#, allows you to quickly integrate quest mechanics into your games. 9 | 10 | ## Features 11 | 12 | * 🌈 Easy to use API 13 | * 🧩 Support for custom quests 14 | * 📋 Tested API with [Xunit](https://xunit.net/) 15 | * 📚 Detailed documentation to help you get started quickly. 16 | * 🔍 C# Based API 17 | 18 | ## Installing 19 | 20 | 1. Download a copy of the `addons` folder 21 | 2. Copy the `addons` folder from the downloaded file to your project directory 22 | 3. Activate the addon in the editor `Project -> Project Settings -> Plugins` 23 | 24 | ## Documentation 25 | 26 | More detailed API documentation can be found in the `docs` folder. 27 | 28 | ## Support and Contributions 29 | 30 | If you think you have found a bug or have a feature request, create new issue. Try to be as detailed as possible in your issue reports. 31 | 32 | If you are interested in contributing fixes or features, please read [contributors](docs/contributing.md) guide first. 33 | -------------------------------------------------------------------------------- /addons/dotnetquestsystem/MainPage.cs: -------------------------------------------------------------------------------- 1 | using Godot; 2 | using System; 3 | using dotnetquestsystem; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | [Tool] 8 | public partial class MainPage : Control 9 | { 10 | [Export] 11 | private NodePath QuestListPath = null!; 12 | 13 | [Export] 14 | private NodePath PanelViewPath = null!; 15 | 16 | [Export] 17 | private NodePath NamePath = null!; 18 | 19 | [Export] 20 | private NodePath DescriptionPath = null!; 21 | 22 | [Export] 23 | private NodePath ObjectivePath = null!; 24 | 25 | [Export] 26 | private NodePath RewardPath = null!; 27 | 28 | [Export] 29 | private NodePath DeleteQuestPath = null!; 30 | 31 | [Export] 32 | private NodePath FileDialogPath = null!; 33 | 34 | private ItemList _questList = null!; 35 | private Panel _panelView = null!; 36 | private LineEdit _nameQuest = null!; 37 | private TextEdit _descriptionQuest = null!; 38 | private TextEdit _objectiveQuest = null!; 39 | private RewardOption _reward = null!; 40 | private Button _deleteQuest = null!; 41 | private FileDialog _fileDialog = null!; 42 | 43 | private string? _saveQuestsPath = null; 44 | private Quest _currentQuest = null!; 45 | private ConfigFile configFile = new ConfigFile(); 46 | 47 | public override void _Ready() 48 | { 49 | InitFields(); 50 | 51 | // We do it once plugin activate 52 | try 53 | { 54 | if(_saveQuestsPath != null){ 55 | QuestManager.instance.questDatabase.LoadQuests(_saveQuestsPath); 56 | } 57 | else{ 58 | QuestManager.instance.questDatabase.LoadQuests(); 59 | } 60 | } 61 | catch (System.IO.FileNotFoundException) 62 | { 63 | GD.Print("Quests json file not found"); 64 | } 65 | 66 | foreach (var quest in QuestManager.instance.questDatabase.Quests) 67 | { 68 | _questList.AddItem(quest.Name); 69 | } 70 | } 71 | 72 | public void OnCreateNewQuestButtonPressed(){ 73 | Quest newQuest = QuestManager.instance.questController 74 | .CreateQuest("New Quest", "New Description", "New Objective"); 75 | 76 | int indexNewQuest = _questList.AddItem(newQuest.Name); 77 | 78 | // Since Select() does not trigger the item selection signal we emmit it directly 79 | _questList.Select(indexNewQuest); 80 | OnQuestItemSelect(indexNewQuest); 81 | } 82 | 83 | public void OnQuestItemSelect(int index){ 84 | if(index < 0 || index >= QuestManager.instance.questDatabase.Quests.Count){ 85 | GD.PushError("Quest index out of bounds, objects on scene created manually?"); 86 | return; 87 | } 88 | 89 | _deleteQuest.Visible = true; 90 | 91 | _currentQuest = QuestManager.instance.questDatabase.Quests[index]; 92 | 93 | _nameQuest.Text = _currentQuest.Name; 94 | _descriptionQuest.Text = _currentQuest.Description; 95 | _objectiveQuest.Text = _currentQuest.Objective; 96 | if(_currentQuest.Reward != null){ 97 | SelectRewardByText(_currentQuest.Reward.GetType().Name.ToString()!.ToLower().Replace("reward", "")); 98 | SetRewardParamsValue(_currentQuest); 99 | _reward.attributeContainer.Visible = true; 100 | } 101 | else{ 102 | _reward.optionRewards.Select(-1); 103 | _reward.attributeContainer.Visible = false; 104 | } 105 | 106 | _panelView.Visible = true; 107 | } 108 | 109 | public void OnSaveQuestButtonPressed(){ 110 | if(_currentQuest == null){ 111 | GD.PushError("Selected quest is null"); 112 | return; 113 | } 114 | 115 | string newName = _nameQuest.Text.Trim(); 116 | if(string.IsNullOrEmpty(newName)){ 117 | GD.PushWarning("Quest name cannot be empty"); 118 | return; 119 | } 120 | 121 | Quest? existsngQuest = QuestManager.instance.questDatabase.Quests.Find((q)=>q.Name == newName && q != _currentQuest); 122 | if(existsngQuest != null){ 123 | GD.PushWarning("A quest with this name already exists"); 124 | return; 125 | } 126 | 127 | int selectedRewardId = _reward.optionRewards.Selected; 128 | IReward? newReward = null; 129 | 130 | QuestManager.instance.questController.DeleteQuest(_currentQuest); 131 | 132 | if(selectedRewardId>=0){ 133 | object[]? rewardParams = GetRewardParamsValue(); 134 | 135 | newReward = Activator.CreateInstance(_reward.RewardableClass[selectedRewardId], rewardParams) as IReward; 136 | } 137 | 138 | if(newReward != null){ 139 | _currentQuest = QuestManager.instance.questController.CreateQuest( 140 | name: newName, 141 | description: _descriptionQuest.Text, 142 | objective: _objectiveQuest.Text, 143 | reward: newReward 144 | ); 145 | } 146 | else{ 147 | _currentQuest = QuestManager.instance.questController.CreateQuest( 148 | name: newName, 149 | description: _descriptionQuest.Text, 150 | objective: _objectiveQuest.Text 151 | ); 152 | } 153 | 154 | int selectedIndex = -1; 155 | int[] selectedItems = _questList.GetSelectedItems(); 156 | 157 | if(selectedItems.Length > 0){ 158 | selectedIndex = selectedItems[0]; 159 | } 160 | if(selectedIndex >= 0){ 161 | _questList.SetItemText(selectedIndex, newName); 162 | } 163 | 164 | GD.Print("Quest saved!"); 165 | } 166 | 167 | public void OnDeleteQuestButtonPressed(){ 168 | int[] selectedItems = _questList.GetSelectedItems(); 169 | int selectedIndex = -1; 170 | 171 | if(selectedItems.Length > 0){ 172 | selectedIndex = selectedItems[0]; 173 | } 174 | if(selectedIndex >= 0){ 175 | _questList.RemoveItem(selectedIndex); 176 | } 177 | 178 | QuestManager.instance.questController.DeleteQuest(_currentQuest); 179 | 180 | _deleteQuest.Visible = false; 181 | _panelView.Visible = false; 182 | 183 | GD.Print("Quest delete!"); 184 | } 185 | 186 | public void OnSelectQuestPathButtonPressed(){ 187 | _fileDialog.Visible = true; 188 | } 189 | 190 | public void OnQuestPathSelect(){ 191 | _fileDialog.Visible = false; 192 | _saveQuestsPath = _fileDialog.CurrentPath + "Quests.json"; 193 | GD.Print("Save quest path change to: "+ _saveQuestsPath); 194 | } 195 | 196 | private void SelectRewardByText(string text){ 197 | int itemCount = _reward.optionRewards.ItemCount; 198 | 199 | for (int i = 0; i < itemCount; i++){ 200 | if(_reward.optionRewards.GetItemText(i) == text){ 201 | _reward.optionRewards.Select(i); 202 | 203 | // Select() method did not emmit OnSelect signal 204 | _reward.OnRewardTypeSelect(i); 205 | return; 206 | } 207 | } 208 | 209 | } 210 | 211 | private object[]? GetRewardParamsValue(){ 212 | List paramsValue = new List(); 213 | var allChild = _reward.attributeContainer.GetChildren(); 214 | 215 | foreach (var child in allChild){ 216 | if(child is Label label){ 217 | int index = allChild.IndexOf(label) + 1; 218 | 219 | if(index < allChild.Count && allChild[index] is Control inputField){ 220 | 221 | if(inputField is SpinBox spinBox){ 222 | if(spinBox.MinValue >= int.MinValue && spinBox.MaxValue <= int.MaxValue){ 223 | paramsValue.Add((int)spinBox.Value); 224 | } 225 | else{ 226 | paramsValue.Add((float)spinBox.Value); 227 | } 228 | } 229 | else if(inputField is LineEdit lineEdit){ 230 | paramsValue.Add(lineEdit.Text); 231 | } 232 | 233 | } 234 | } 235 | } 236 | 237 | return paramsValue.Count > 0 ? paramsValue.ToArray() : null; 238 | } 239 | 240 | private void SetRewardParamsValue(Quest questWithReward){ 241 | if(questWithReward.Reward is null) return; 242 | 243 | var allChild = _reward.attributeContainer.GetChildren(); 244 | var rewardProperties = questWithReward.Reward.GetType().GetProperties(); 245 | 246 | int valueIndex = 0; 247 | 248 | foreach (var child in allChild){ 249 | if(child is Label label){ 250 | int index = allChild.IndexOf(label)+1; 251 | 252 | if(index < allChild.Count && allChild[index] is Control inputField){ 253 | if(valueIndex >= rewardProperties.Count()){ 254 | GD.PushWarning("Reward count is out of bounds"); 255 | } 256 | 257 | if(inputField is SpinBox spinBox){ 258 | if (rewardProperties[valueIndex].GetValue(questWithReward.Reward) is int intValue && 259 | spinBox.MinValue >= int.MinValue && spinBox.MaxValue <= int.MaxValue){ 260 | spinBox.Value = intValue; 261 | } 262 | else if (rewardProperties[valueIndex].GetValue(questWithReward.Reward) is float floatValue){ 263 | spinBox.Value = floatValue; 264 | } 265 | } 266 | else if (inputField is LineEdit lineEdit){ 267 | if (rewardProperties[valueIndex].GetValue(questWithReward.Reward) is string stringValue){ 268 | lineEdit.Text = stringValue; 269 | } 270 | } 271 | 272 | valueIndex++; 273 | 274 | } 275 | } 276 | } 277 | } 278 | 279 | private void InitFields(){ 280 | var err = configFile.Load("res://quest.cfg"); 281 | if(err == Error.Ok){ 282 | _saveQuestsPath = (string?)configFile.GetValue("settings","save_quest_path"); 283 | } 284 | 285 | _questList = GetNode(QuestListPath); 286 | _panelView = GetNode(PanelViewPath); 287 | _descriptionQuest = GetNode(DescriptionPath); 288 | _nameQuest = GetNode(NamePath); 289 | _objectiveQuest = GetNode(ObjectivePath); 290 | _reward = GetNode(RewardPath); 291 | _deleteQuest = GetNode