diff --git a/README.md b/README.md index 232e600..596cdfb 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,31 @@ # godot-ink -**This project is heavily under construction as C# support in Godot is a WIP.** +An [ink](https://github.com/inkle/ink) integration for [Godot Engine](https://github.com/godotengine/godot). -[Ink](https://github.com/inkle/ink) integration for [Godot Engine](https://github.com/godotengine/godot). -As C# supprt in Godot is still in its early stages, you might need to manually add the addon files to your `.csproj`. - -### Currently supported features: -* Running an Ink story and branching with choice indexes -* Saving and loading Ink state -* Tags -* Knot/Stitch jumping -* Getting/Setting Ink variables (InkLists aren't supported yet) -* Observing Ink variables (Inklists aren't supported yet) -* External function bindings -* Read/Visit counts - -### TODO: -* Getting/Setting/Observing InkLists -* On the fly Ink to JSON compilation - -## Troubleshooting - -If you're having trouble enabling the editor plugin, it's probably because the `.cs` files aren't compiling with your project. You can solve the issue by adding this `ItemGroup` to your `.csproj` file. - -```xml - - - - - - $(ProjectDir)/ink-engine-runtime.dll - False - - -``` - -Depending on the version of Godot you're using, you might still have issues with the editor plugin. -Do not worry, you don't actually need to enable it to use **godot-ink**. If you don't want to bother with extensive troubleshooting, all you have to do is attach `addons/paulloz.ink/Story.cs` to a node (or use it as a singleton). This node will become the `Story` node for the rest of this documentation. - -### Game export +## How to use -As your `.json` files aren't Godot resources, you'll need to manually tell the engine to include them in the exported package. You can read more about that in the [documentation](https://godot.readthedocs.io/en/latest/getting_started/workflow/export/exporting_projects.html?highlight=export#export-mode). +When the plugin is properly loaded, you should be able to use the new ink panel to inspect your story. -## How to use +![](inspector_screenshot.png) -You'll need to put `ink-engine-runtime.dll` at the root of your Godot project. +You'll also see a new `ink` section in your project settings. If you want to be able to compile your .ink files on the fly you can input the path to the inklecate binary here. +The last thing you'll need to do in order to get going is to put `ink-engine-runtime.dll` at the root of your Godot project. -Everything revolves around the `Story` node. +--- +Everything is handled in a `Story` node. If nothing is specified, the **C#** usage is the same as the **GDScript** one. ### Loading the story +First you should navigate to your `.json` or `.ink` file and import it as an `Ink story` in Godot. To do that, select the file in Godot, go to `Import`, select `Ink story` under `Import As:` and click `ReImport`. + +![](import_screenshot.png) + To load your story, you can: -* Point the `InkFilePath` exported variable to the location of your JSON Ink file and check the `AutoLoadStory` checkbox in the inspector. -* Point the `InkFilePath` exported variable to the location of your JSON Ink file (in the inspector or via a script) and call `story.LoadStory()`. -* Call `story.LoadStory(path)` with path pointing to the location of your JSON Ink file. +* Point the `InkFile` exported variable to your `.json`/`.ink` file and check the `AutoLoadStory` checkbox in the inspector. +* Point the `InkFile` exported variable to your `.json`/`.ink` file (in the inspector or via a script) and call `story.LoadStory()`. ### Running the story and making choices @@ -111,7 +80,8 @@ You get and set the json state by calling `.GetState()` and `.SetState(String)`. story.SetState(story.GetState()) ``` -Alternatively you can save and load directly from disk (either by passing a path or a file as argument) with `.LoadStateFromDisk` and `.SaveStateOnDisk`. +Alternatively you can save and load directly from disk (either by passing a path or a file as argument) with `.LoadStateFromDisk` and `.SaveStateOnDisk`. +When using a path, the default behaviour is to use the `user://` folder. You can bypass this by passing a full path to the functions (e.g. `res://my_dope_save_file.json`). ```GDScript story.SaveStateOnDisk("save.json") @@ -178,6 +148,29 @@ You can know how many times a knot/stitch has been visited with `.VisitCountPath print(story.VisitCountPathString("mycoolknot.myradstitch")) ``` +## Troubleshooting + +If you're having trouble enabling the editor plugin, it's probably because the `.cs` files aren't compiling with your project. You can solve the issue by adding this `ItemGroup` to your `.csproj` file. + +```xml + + + + + + $(ProjectDir)/ink-engine-runtime.dll + False + + +``` + +Depending on the version of Godot you're using, you might still have issues with the editor plugin. +Do not worry, you don't actually need to enable it to use **godot-ink**. If you don't want to bother with extensive troubleshooting, all you have to do is attach `addons/paulloz.ink/Story.cs` to a node (or use it as a singleton). This node will become the `Story` node for the rest of this documentation. + +### TODO: +* Getting/Setting/Observing InkLists +* On the fly Ink to JSON compilation (works on Windows, need some tweaking for Linux and Mac OS support) + ## License **godot-ink** is released under MIT license (see the [LICENSE](/LICENSE) file for more information). diff --git a/addons/paulloz.ink/InkDock.cs b/addons/paulloz.ink/InkDock.cs index 26774c2..94579ae 100644 --- a/addons/paulloz.ink/InkDock.cs +++ b/addons/paulloz.ink/InkDock.cs @@ -11,23 +11,26 @@ public class InkDock : Control private String currentFilePath; private Node storyNode; - private RichTextLabel storyText; + private VBoxContainer storyText; private VBoxContainer storyChoices; + + private ScrollBar scrollbar; public override void _Ready() { - fileSelect = GetNode("VBoxContainer/GridContainer/OptionButton"); + fileSelect = GetNode("Container/Top/OptionButton"); fileDialog = GetNode("FileDialog"); fileSelect.Connect("item_selected", this, nameof(onFileSelectItemSelected)); fileDialog.Connect("file_selected", this, nameof(onFileDialogFileSelected)); fileDialog.Connect("popup_hide", this, nameof(onFileDialogHide)); storyNode = GetNode("Story"); - storyNode.SetScript(ResourceLoader.Load("res://addons/paulloz.ink/Story.cs") as Script); - storyNode.Connect(nameof(Story.InkContinued), this, nameof(onStoryContinued)); - storyNode.Connect(nameof(Story.InkChoices), this, nameof(onStoryChoices)); - storyText = GetNode("VBoxContainer/StoryText"); - storyChoices = GetNode("VBoxContainer/StoryChoices"); + storyNode.SetScript(ResourceLoader.Load("res://addons/paulloz.ink/InkStory.cs") as Script); + storyNode.Connect(nameof(InkStory.InkChoices), this, nameof(onStoryChoices)); + storyText = GetNode("Container/Bottom/Scroll/Margin/StoryText"); + storyChoices = GetNode("Container/Bottom/StoryChoices"); + + scrollbar = this.GetNode("Container/Bottom/Scroll").GetVScrollbar(); } private void resetFileSelectItems() @@ -38,7 +41,18 @@ private void resetFileSelectItems() private void resetStoryContent() { - storyText.Text = ""; + this.removeAllStoryContent(); + this.removeAllChoices(); + } + + private void removeAllStoryContent() + { + foreach (Node n in storyText.GetChildren()) + storyText.RemoveChild(n); + } + + private void removeAllChoices() + { foreach (Node n in storyChoices.GetChildren()) storyChoices.RemoveChild(n); } @@ -75,21 +89,37 @@ private void onFileDialogHide() else { fileSelect.Select(2); - storyNode.Set("InkFilePath", currentFilePath.Remove(0, 6)); + storyNode.Set("InkFile", ResourceLoader.Load(currentFilePath)); storyNode.Call("LoadStory"); + resetStoryContent(); continueStoryMaximally(); } } - private void continueStoryMaximally() + private async void continueStoryMaximally() { while ((bool)storyNode.Get("CanContinue")) - storyNode.Call("Continue"); + { + try + { + storyNode.Call("Continue"); + onStoryContinued(storyNode.Get("CurrentText") as String, new String[] { }); + } + catch (Ink.Runtime.StoryException e) + { + onStoryContinued(e.ToString(), new String[] { }); + } + } + await ToSignal(GetTree(), "idle_frame"); + this.scrollbar.Value = this.scrollbar.MaxValue; } private void onStoryContinued(String text, String[] tags) { - storyText.Text = (storyText.Text + text).TrimStart(new char[] { ' ', '\n' }); + Label newLine = new Label(); + newLine.Autowrap = true; + newLine.Text = text.Trim(new char[] { ' ', '\n' }); + this.storyText.AddChild(newLine); } private void onStoryChoices(String[] choices) @@ -107,8 +137,9 @@ private void onStoryChoices(String[] choices) private void clickChoice(int idx) { - resetStoryContent(); storyNode.Callv("ChooseChoiceIndex", new Godot.Collections.Array() { idx }); + this.removeAllChoices(); + this.storyText.AddChild(new HSeparator()); continueStoryMaximally(); } } diff --git a/addons/paulloz.ink/InkDock.tscn b/addons/paulloz.ink/InkDock.tscn index 02eb98b..d515701 100755 --- a/addons/paulloz.ink/InkDock.tscn +++ b/addons/paulloz.ink/InkDock.tscn @@ -1,52 +1,82 @@ -[gd_scene load_steps=3 format=2] +[gd_scene load_steps=4 format=2] [ext_resource path="res://addons/paulloz.ink/InkDock.cs" type="Script" id=1] -[ext_resource path="res://addons/paulloz.ink/Story.cs" type="Script" id=2] +[ext_resource path="res://addons/paulloz.ink/InkStory.cs" type="Script" id=2] + +[sub_resource type="StyleBoxFlat" id=1] +bg_color = Color( 0.137255, 0.160784, 0.184314, 1 ) [node name="Ink" type="Control"] anchor_right = 1.0 anchor_bottom = 1.0 +rect_min_size = Vector2( 0, 200 ) +size_flags_horizontal = 3 +size_flags_vertical = 3 script = ExtResource( 1 ) -[node name="VBoxContainer" type="VBoxContainer" parent="."] +[node name="Container" type="VBoxContainer" parent="."] anchor_right = 1.0 -margin_bottom = 24.0 +anchor_bottom = 1.0 size_flags_horizontal = 3 size_flags_vertical = 3 -[node name="GridContainer" type="GridContainer" parent="VBoxContainer"] +[node name="Top" type="HBoxContainer" parent="Container"] +editor/display_folded = true margin_right = 1024.0 margin_bottom = 20.0 -size_flags_horizontal = 3 -size_flags_vertical = 0 -columns = 2 -[node name="Label" type="Label" parent="VBoxContainer/GridContainer"] +[node name="Label" type="Label" parent="Container/Top"] margin_top = 3.0 margin_right = 40.0 margin_bottom = 17.0 text = "Story :" -[node name="OptionButton" type="OptionButton" parent="VBoxContainer/GridContainer"] +[node name="OptionButton" type="OptionButton" parent="Container/Top"] margin_left = 44.0 margin_right = 85.0 margin_bottom = 20.0 items = [ "", null, false, 0, null, "Load", null, false, 1, null ] selected = 0 -[node name="StoryText" type="RichTextLabel" parent="VBoxContainer"] +[node name="Bottom" type="HBoxContainer" parent="Container"] margin_top = 24.0 margin_right = 1024.0 -margin_bottom = 624.0 -rect_min_size = Vector2( 100, 600 ) +margin_bottom = 600.0 size_flags_horizontal = 3 size_flags_vertical = 3 -custom_colors/default_color = Color( 1, 1, 1, 1 ) -[node name="StoryChoices" type="VBoxContainer" parent="VBoxContainer"] -margin_top = 628.0 +[node name="Scroll" type="ScrollContainer" parent="Container/Bottom"] +margin_right = 820.0 +margin_bottom = 576.0 +size_flags_horizontal = 11 +size_flags_vertical = 3 +custom_styles/bg = SubResource( 1 ) +scroll_horizontal_enabled = false + +[node name="Margin" type="MarginContainer" parent="Container/Bottom/Scroll"] +margin_right = 820.0 +margin_bottom = 576.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +custom_constants/margin_right = 10 +custom_constants/margin_top = 10 +custom_constants/margin_left = 10 +custom_constants/margin_bottom = 10 + +[node name="StoryText" type="VBoxContainer" parent="Container/Bottom/Scroll/Margin"] +margin_left = 10.0 +margin_top = 10.0 +margin_right = 810.0 +margin_bottom = 566.0 +size_flags_horizontal = 11 +size_flags_vertical = 3 + +[node name="StoryChoices" type="VBoxContainer" parent="Container/Bottom"] +margin_left = 824.0 margin_right = 1024.0 -margin_bottom = 628.0 +margin_bottom = 576.0 +rect_min_size = Vector2( 200, 0 ) +custom_constants/separation = 10 [node name="FileDialog" type="FileDialog" parent="."] margin_right = 461.0 @@ -57,4 +87,3 @@ filters = PoolStringArray( "*.json" ) [node name="Story" type="Node" parent="."] script = ExtResource( 2 ) - diff --git a/addons/paulloz.ink/Story.cs b/addons/paulloz.ink/InkStory.cs similarity index 85% rename from addons/paulloz.ink/Story.cs rename to addons/paulloz.ink/InkStory.cs index 5a9263e..36a7892 100644 --- a/addons/paulloz.ink/Story.cs +++ b/addons/paulloz.ink/InkStory.cs @@ -5,7 +5,7 @@ #if TOOLS [Tool] #endif -public class Story : Node +public class InkStory : Node { // All the signals we'll need [Signal] public delegate void InkContinued(String text, String[] tags); @@ -20,7 +20,7 @@ private String ObservedVariableSignalName(String name) // All the exported variables [Export] public Boolean AutoLoadStory = false; - [Export] public String InkFilePath = null; + [Export] public TextFile InkFile = null; // All the public variables public String CurrentText = ""; @@ -55,48 +55,24 @@ public override void _Ready() this.LoadStory(); } - public void LoadStory() - { - this.LoadStory(this.InkFilePath); - } - - public Boolean LoadStory(String inkFilePath) + public Boolean LoadStory() { this.reset(); - try - { - if (inkFilePath == null) - throw new System.IO.FileNotFoundException(String.Format("Unable to find {0}.", null)); - - this.InkFilePath = inkFilePath; - - String path = this.InkFilePath.StartsWith("res://") ? this.InkFilePath : String.Format("res://{0}", this.InkFilePath); - File file = new File(); - if (file.FileExists(path)) - { - // Load the story - file.Open(path, (int)File.ModeFlags.Read); - this.story = new Ink.Runtime.Story(file.GetAsText()); - file.Close(); - } - else - throw new System.IO.FileNotFoundException(String.Format("Unable to find {0}.", path)); - } - catch (System.IO.FileNotFoundException e) - { - GD.PrintErr(e.ToString()); - + if (!this.isJSONFileValid()) return false; - } + this.story = new Ink.Runtime.Story(this.InkFile.GetMeta("content") as String); return true; } - public void LoadStory(String inkFilePath, String state) + public Boolean LoadStory(String state) { - if (this.LoadStory(inkFilePath)) + if (this.LoadStory()) this.SetState(state); + else + return false; + return true; } public String Continue() @@ -275,4 +251,9 @@ public void LoadStateFromDisk(File file) this.story.state.LoadJson(file.GetAsText()); } } + + private Boolean isJSONFileValid() + { + return this.InkFile != null && this.InkFile.HasMeta("content"); + } } diff --git a/addons/paulloz.ink/PaullozDotInk.cs b/addons/paulloz.ink/PaullozDotInk.cs index fc90126..12dc0f0 100644 --- a/addons/paulloz.ink/PaullozDotInk.cs +++ b/addons/paulloz.ink/PaullozDotInk.cs @@ -1,31 +1,68 @@ #if TOOLS using Godot; +using Godot.Collections; using System; [Tool] public class PaullozDotInk : EditorPlugin { + private Dictionary settings = new Dictionary() { {"inklecate_path", "---"} }; private const String addonBasePath = "res://addons/paulloz.ink"; - private NodePath customTypeScriptPath = $"{addonBasePath}/Story.cs"; + private NodePath customTypeScriptPath = $"{addonBasePath}/InkStory.cs"; private NodePath customTypeIconPath = $"{addonBasePath}/icon.svg"; private NodePath dockScene = $"{addonBasePath}/InkDock.tscn"; private Control dock; + private NodePath importPluginScriptPath = $"{addonBasePath}/import_ink.gd"; + private EditorImportPlugin importPlugin; + public override void _EnterTree() { - AddCustomType("Story", "Node", ResourceLoader.Load(customTypeScriptPath) as Script, ResourceLoader.Load(customTypeIconPath) as Texture); + // Settings + foreach (String key in settings.Keys) + { + String property_name = $"ink/{key}"; + if (!ProjectSettings.HasSetting(property_name)) + { + ProjectSettings.SetSetting(property_name, settings[key]); + ProjectSettings.SetInitialValue(property_name, settings[key]); + } + } + ProjectSettings.Save(); + + // Resources + importPlugin = (ResourceLoader.Load(importPluginScriptPath) as GDScript).New() as EditorImportPlugin; + AddImportPlugin(importPlugin); + + // Custom types + AddCustomType("Ink Story", "Node", ResourceLoader.Load(customTypeScriptPath) as Script, ResourceLoader.Load(customTypeIconPath) as Texture); + // Editor dock = (ResourceLoader.Load(dockScene) as PackedScene).Instance() as Control; - AddControlToDock(EditorPlugin.DockSlot.RightUl, dock); + AddControlToBottomPanel(dock, "Ink"); } public override void _ExitTree() { - RemoveControlFromDocks(dock); + // Editor + RemoveControlFromBottomPanel(dock); + dock.Free(); + + // Custom types + RemoveCustomType("Ink Story"); + + // Resources + RemoveImportPlugin(importPlugin); - RemoveCustomType("Story"); + // Settings + foreach (String key in settings.Keys) + { + String property_name = $"ink/{key}"; + if (ProjectSettings.HasSetting(property_name)) + ProjectSettings.SetSetting(property_name, null); + } } } #endif \ No newline at end of file diff --git a/addons/paulloz.ink/icon.svg b/addons/paulloz.ink/icon.svg index 3becfa4..dc29217 100644 --- a/addons/paulloz.ink/icon.svg +++ b/addons/paulloz.ink/icon.svg @@ -1,14 +1,7 @@ - - - - + + + + + + + \ No newline at end of file diff --git a/addons/paulloz.ink/import_ink.gd b/addons/paulloz.ink/import_ink.gd new file mode 100644 index 0000000..376fef0 --- /dev/null +++ b/addons/paulloz.ink/import_ink.gd @@ -0,0 +1,72 @@ +tool +extends EditorImportPlugin + +func get_importer_name(): + return "ink"; + +func get_visible_name(): + return "Ink story"; + +func get_recognized_extensions(): + return [ "json", "ink" ]; + +func get_save_extension(): + return "res"; + +func get_resource_type(): + return "TextFile"; + +func get_import_options(preset): + return [] + +func get_preset_count(): + return 0 + +func import(source_file, save_path, options, r_platform_variants, r_gen_files): + match source_file.split(".")[-1].to_lower(): + "ink": + return import_from_ink(source_file, save_path) + "json": + return import_from_json(source_file, save_path) + +func import_from_ink(source_file, save_path): + if ProjectSettings.has_setting("ink/inklecate_path"): + var inklecate = ProjectSettings.get_setting("ink/inklecate_path") + if inklecate != "---": + var new_file = "%d.json" % int(randf() * 100000) + + var err = OS.execute(inklecate, [ + "-o", "%s/%s" % [OS.get_user_data_dir(), new_file], + ProjectSettings.globalize_path(source_file) + ], true) + + new_file = "user://%s" % new_file + if !File.new().file_exists(new_file): + return ERR_FILE_UNRECOGNIZED + var ret = import_from_json(new_file, save_path) + + Directory.new().remove(new_file) + return ret + +func import_from_json(source_file, save_path): + var raw_content = get_source_file_content(source_file) + + var parsed_content = parse_json(raw_content) + if !parsed_content.has("inkVersion"): + return ERR_FILE_UNRECOGNIZED + + var resource = TextFile.new() + resource.set_meta("content", raw_content); + + return ResourceSaver.save("%s.%s" % [save_path, get_save_extension()], resource) + +func get_source_file_content(source_file): + var file = File.new() + var err = file.open(source_file, File.READ) + if err != OK: + return err + + var raw_content = file.get_as_text() + + file.close() + return raw_content \ No newline at end of file diff --git a/import_screenshot.png b/import_screenshot.png new file mode 100644 index 0000000..bb40015 Binary files /dev/null and b/import_screenshot.png differ diff --git a/inspector_screenshot.png b/inspector_screenshot.png new file mode 100644 index 0000000..3683f44 Binary files /dev/null and b/inspector_screenshot.png differ