Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover

This commit is contained in:
cthangover committed 2026-10-22 17:09:38 +02:00
1 parent 2b8eaa5452
commit 972df5ccff
17 files changed
+1692 -3

No files matched your search

+26 -1
View File
@@ -36,6 +36,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private Label _filePathLabel;
private bool _playActive;
private string _pendingKey;
private string _returnRoute;
private string _returnScene;
private CanvasLayer _stopOverlay;
@@ -47,10 +48,31 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
}
/// <summary>Opens with the graph view so the editor starts on the cross-scene map.</summary>
/// <summary>Opens with the graph view so the editor starts on the cross-scene map, or on the file asked for by <see cref="OpenScenario"/>.</summary>
public override void _Ready()
{
_editTabs.SetViewToGraph();
if (_pendingKey != null)
OpenScenario(_pendingKey);
}
/// <summary>
/// Opens a scenario file (key <c>mod:path</c>) in a text tab and selects it in the
/// tree — for other tools, e.g. the scene editor's "open" button. Before the window
/// is in the tree the key waits for <see cref="_Ready"/>, which would otherwise
/// switch the view back to the graph.
/// </summary>
public void OpenScenario(string key)
{
if (!IsInsideTree())
{
_pendingKey = key;
return;
}
_pendingKey = null;
_fileTreePanel.SelectFile(key);
_editTabs.OpenFile(key);
}
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
@@ -222,6 +244,9 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
_editTabs.SaveCurrentFile();
// The running game caches scenario headers and texts: drop them, so a new or
// edited scenario plays on the next entry into its scene without a mod reload.
SceneFileService.RefreshGame();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok);
_infoPanel.Update(text);
_thumbPanel.Load(text);
+69
View File
@@ -0,0 +1,69 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>
/// One background of the edited scene in <see cref="SceneEditorWindow"/>: a thumbnail
/// (click shows it in the preview), the background id and the mod whose scene file
/// adds it, and a remove button only for backgrounds of the file being edited —
/// another mod's background can only be hidden by editing that mod.
/// </summary>
public partial class SceneBackgroundTile : PanelContainer
{
private readonly string _background;
private readonly Action<string> _picked;
private readonly Action<string> _removed;
/// <summary>Builds the tile; <paramref name="removable"/> shows the ✕ button, <paramref name="selected"/> the accent frame.</summary>
public SceneBackgroundTile(string background, string modId, bool removable, bool selected,
Action<string> picked, Action<string> removed)
{
_background = background;
_picked = picked;
_removed = removed;
AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1,
selected ? UiTokens.Accent : UiTokens.Line));
var column = new VBoxContainer();
column.AddThemeConstantOverride("separation", UiTokens.Space1);
AddChild(column);
var thumb = new TextureButton
{
TextureNormal = ModResourceService.LoadBackgroundTexture(background),
IgnoreTextureSize = true,
StretchMode = TextureButton.StretchModeEnum.KeepAspectCentered,
CustomMinimumSize = SceneEditorWindow.ThumbSize,
TooltipText = background
};
thumb.Pressed += OnPicked;
column.AddChild(thumb);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
var caption = ToolUi.Hint(background + " · " + modId);
caption.TooltipText = background + " (" + modId + ")";
caption.MouseFilter = MouseFilterEnum.Pass;
caption.SizeFlagsHorizontal = SizeFlags.ExpandFill;
caption.CustomMinimumSize = new Vector2(SceneEditorWindow.ThumbSize.X - 32, 0);
caption.TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis;
row.AddChild(caption);
if (removable)
{
var remove = UiKit.IconButton("✕", ToolUi.Tr("tools/scene_editor/remove_background"));
remove.Pressed += OnRemoved;
row.AddChild(remove);
}
}
private void OnPicked() => _picked?.Invoke(_background);
private void OnRemoved() => _removed?.Invoke(_background);
}
}
@@ -0,0 +1,17 @@
using Cthangover.Core.UI.Tool;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>Toolbox button that opens the scene editor. Always visible.</summary>
public class SceneEditorToolBoxButton : IToolBoxButton
{
/// <summary>Matches <see cref="SceneEditorToolProvider.Id"/>.</summary>
public string ToolId => "scene_editor";
/// <summary>No custom icon — uses text label.</summary>
public string IconPath => "";
/// <summary>Translation key for the button label.</summary>
public string LocaleKey => "tools/scene_editor/title";
/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible() => true;
}
}
@@ -0,0 +1,19 @@
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>Registers the scene editor as a tool window (hub, main menu tools, in-game toolbox).</summary>
public class SceneEditorToolProvider : IToolProvider
{
/// <summary>Unique tool identifier.</summary>
public string Id => "scene_editor";
/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey => "tools/scene_editor/title";
/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow() => new SceneEditorWindow();
/// <summary>Adds the launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
+743
View File
@@ -0,0 +1,743 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Audio;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.SceneEditor
{
/// <summary>
/// Editor of logical scenes — the places of a novel: which backgrounds a scene shows
/// (one of them at random on every entry), its ambient sound, the scenario played
/// when nothing else starts, and the scenarios that happen there. Left: every scene
/// of the loaded mods; centre: the background preview, the scene's backgrounds and a
/// searchable list of all backgrounds to add; right: properties and the scene's
/// scenarios (open one in the scenario editor, or start a new one).
/// <para/>
/// A scene is the merge of the <c>scenes/&lt;name&gt;.json</c> files of all mods
/// (backgrounds add up, a later ambient or default scenario wins). Scenarios are optional:
/// a place may hold only interactives, so a new scene starts without any. The editor writes
/// one of them — the file of the mod chosen in "Save to": the scene's own file when
/// that mod defines it, otherwise a new file that adds to a foreign scene without
/// touching the foreign mod. Backgrounds of the other files are shown locked, with
/// their mod. After saving the game re-reads scenes (<see cref="SceneFileService.RefreshGame"/>),
/// so a new scene can be entered at once with "Go to scene".
/// </summary>
public partial class SceneEditorWindow : ToolWindow
{
private const int ThumbWidth = 160;
private const int ThumbHeight = 90;
private readonly List<SceneFileEntry> _files = new();
private readonly List<string> _writableMods = new();
private readonly List<string> _sounds = new();
private readonly List<string> _scenarioKeys = new();
private readonly List<string> _defaultChoices = new();
private string _sceneName;
private bool _isNew;
private SceneFileEntry _target;
private string _preview;
private bool _suppress;
private bool _ambientPlaying;
private string _pendingMod;
private string _pendingScene;
private FilteredList _sceneList;
private TextureRect _previewRect;
private Label _previewCaption;
private HFlowContainer _backgroundRow;
private FilteredList _picker;
private Button _addBackgroundBtn;
private LineEdit _nameEdit;
private OptionButton _modSelect;
private Label _modHint;
private OptionButton _ambientSelect;
private Button _ambientPlayBtn;
private Label _ambientHint;
private OptionButton _defaultSelect;
private Label _defaultHint;
private ItemList _scenarioList;
private Button _openScenarioBtn;
private Button _newScenarioBtn;
private Button _deleteBtn;
private Button _goBtn;
public SceneEditorWindow() : base("tools/scene_editor/title")
{
CthangoverTheme.Apply(this);
BuildUI();
LoadSources();
SetHint(ToolUi.Tr("tools/scene_editor/hint"));
RefreshAll();
}
// ── UI construction ────────────────────────────────────────
private void BuildUI()
{
var outer = CreateFillContainer();
AddChild(outer);
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/scene_editor/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/scene_editor/new_scene", NewScene, ButtonKind.Secondary, "tools/scene_editor/new_scene_tip"));
_deleteBtn = ToolUi.Button("tools/scene_editor/delete", AskDelete, ButtonKind.Danger, "tools/scene_editor/delete_tip");
toolbar.AddChild(_deleteBtn);
toolbar.AddChild(ToolUi.Divider());
_goBtn = ToolUi.Button("tools/scene_editor/go", GoToScene, ButtonKind.Secondary, "tools/scene_editor/go_tip");
toolbar.AddChild(_goBtn);
toolbar.AddChild(UiKit.Spacer());
toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", Rescan, ButtonKind.Secondary, "tools/scene_editor/rescan_tip"));
var main = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(main);
var left = new VBoxContainer();
main.AddChild(CreateSidebar(left, 260));
left.AddChild(ToolUi.Section("tools/scene_editor/scenes"));
_sceneList = new FilteredList(200);
_sceneList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_sceneList.Chosen += OnScenePicked;
left.AddChild(_sceneList);
var rest = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
main.AddChild(rest);
rest.AddChild(BuildCentre());
var right = new VBoxContainer();
right.AddThemeConstantOverride("separation", UiTokens.Space2);
rest.AddChild(CreateSidebar(right, 340));
BuildProperties(right);
}
private Control BuildCentre()
{
var column = new VBoxContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
column.AddThemeConstantOverride("separation", UiTokens.Space2);
var frame = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 260)
};
frame.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
column.AddChild(frame);
_previewRect = new TextureRect
{
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
MouseFilter = Control.MouseFilterEnum.Ignore
};
frame.AddChild(_previewRect);
_previewCaption = ToolUi.Hint(string.Empty);
column.AddChild(_previewCaption);
column.AddChild(ToolUi.Section("tools/scene_editor/backgrounds"));
_backgroundRow = new HFlowContainer();
_backgroundRow.AddThemeConstantOverride("h_separation", UiTokens.Space2);
_backgroundRow.AddThemeConstantOverride("v_separation", UiTokens.Space2);
column.AddChild(_backgroundRow);
column.AddChild(ToolUi.Section("tools/scene_editor/all_backgrounds"));
_picker = new FilteredList(120);
_picker.Chosen += ShowPreview;
_picker.Activated += AddBackground;
column.AddChild(_picker);
_addBackgroundBtn = ToolUi.Button("tools/scene_editor/add_background", AddPreviewed, ButtonKind.Secondary, "tools/scene_editor/add_background_tip");
column.AddChild(_addBackgroundBtn);
return column;
}
private void BuildProperties(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/scene_editor/properties"));
_nameEdit = ToolUi.Field(column, "tools/scene_editor/name", ToolUi.Text("my_scene", OnNameChanged), "tools/scene_editor/name_tip");
_modSelect = ToolUi.Field(column, "tools/scene_editor/mod", new OptionButton(), "tools/scene_editor/mod_tip");
_modSelect.ItemSelected += OnModPicked;
_modHint = ToolUi.Hint(string.Empty);
column.AddChild(_modHint);
var ambientRow = new HBoxContainer();
ambientRow.AddThemeConstantOverride("separation", UiTokens.Space1);
_ambientSelect = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, FitToLongestItem = false };
_ambientSelect.ItemSelected += OnAmbientPicked;
ambientRow.AddChild(_ambientSelect);
_ambientPlayBtn = ToolUi.Button("tools/scene_editor/listen", ToggleAmbientPreview, ButtonKind.Secondary, "tools/scene_editor/listen_tip");
ambientRow.AddChild(_ambientPlayBtn);
ToolUi.Field(column, "tools/scene_editor/ambient", ambientRow, "tools/scene_editor/ambient_tip");
_ambientHint = ToolUi.Hint(string.Empty);
column.AddChild(_ambientHint);
_defaultSelect = ToolUi.Field(column, "tools/scene_editor/default_scenario",
new OptionButton { FitToLongestItem = false }, "tools/scene_editor/default_scenario_tip");
_defaultSelect.ItemSelected += OnDefaultPicked;
_defaultHint = ToolUi.Hint(string.Empty);
column.AddChild(_defaultHint);
column.AddChild(ToolUi.Section("tools/scene_editor/scenarios"));
_scenarioList = new ItemList { CustomMinimumSize = new Vector2(0, 140), SizeFlagsVertical = Control.SizeFlags.ExpandFill };
_scenarioList.ItemActivated += OnScenarioActivated;
column.AddChild(_scenarioList);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
_openScenarioBtn = ToolUi.Button("tools/scene_editor/open_scenario", OpenSelectedScenario, ButtonKind.Secondary, "tools/scene_editor/open_scenario_tip");
_openScenarioBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_openScenarioBtn);
_newScenarioBtn = ToolUi.Button("tools/scene_editor/new_scenario", NewScenario, ButtonKind.Secondary, "tools/scene_editor/new_scenario_tip");
_newScenarioBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_newScenarioBtn);
}
// ── loading ────────────────────────────────────────────────
private void LoadSources()
{
_files.Clear();
_files.AddRange(SceneFileService.Scan());
_writableMods.Clear();
_writableMods.AddRange(ModFileService.ListWritableMods());
_sounds.Clear();
_sounds.AddRange(ModResourceService.GetSoundIds());
_picker.SetItems(ModResourceService.GetBackgroundIds());
_modSelect.Clear();
foreach (var mod in _writableMods)
_modSelect.AddItem(mod);
_ambientSelect.Clear();
_ambientSelect.AddItem(ToolUi.Tr("tools/scene_editor/none"));
foreach (var sound in _sounds)
_ambientSelect.AddItem(sound);
RebuildSceneList();
}
private void RebuildSceneList()
{
var items = new List<(string Id, string Text, Texture2D Icon)>();
foreach (var group in _files.GroupBy(f => f.Name).OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
items.Add((group.Key, group.Key + " · " + string.Join(", ", group.Select(f => f.ModId).Distinct()), null));
_sceneList.SetItems(items);
_sceneList.Select(_isNew ? null : _sceneName);
}
private void OnScenePicked(string name)
{
if (name == _sceneName && !_isNew)
return;
_pendingScene = name;
RunDiscardingChanges(OpenPendingScene, KeepSceneSelection);
}
private void OpenPendingScene() => OpenScene(_pendingScene);
private void KeepSceneSelection() => _sceneList.Select(_isNew ? null : _sceneName);
private void OpenScene(string name)
{
_sceneName = name;
_isNew = false;
var target = FilesOf(name).LastOrDefault(f => _writableMods.Contains(f.ModId))?.ModId
?? _writableMods.FirstOrDefault();
LoadTarget(target);
_preview = Merged().FirstOrDefault().Background;
MarkClean();
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/opened"), name));
RefreshAll();
}
/// <summary>Takes the chosen mod's file of the current scene as the edited one (a copy), or a new empty file.</summary>
private void LoadTarget(string modId)
{
if (modId == null)
{
_target = null;
return;
}
var own = FilesOf(_sceneName).FirstOrDefault(f => f.ModId == modId);
_target = new SceneFileEntry
{
ModId = modId,
RelativePath = own?.RelativePath ?? SceneFileEntry.PathFor(_sceneName ?? string.Empty),
Name = _sceneName,
Backgrounds = new List<string>(own?.Backgrounds ?? new List<string>()),
Ambient = own?.Ambient ?? string.Empty,
DefaultScenario = own?.DefaultScenario ?? string.Empty,
};
}
private IEnumerable<SceneFileEntry> FilesOf(string name) => _files.Where(f => f.Name == name);
/// <summary>
/// The scene as the game will merge it after saving: the other mods' files in load
/// order with the edited file at its mod's place. Yields each background with the
/// file it comes from.
/// </summary>
private IEnumerable<(string Background, SceneFileEntry File)> Merged()
{
foreach (var file in MergedFiles())
{
foreach (var background in file.Backgrounds)
yield return (background, file);
}
}
private List<SceneFileEntry> MergedFiles()
{
var order = ModRegistry.Instance.GetOrderedModIds();
var result = FilesOf(_sceneName).Where(f => _target == null || f.ModId != _target.ModId).ToList();
if (_target != null)
result.Add(_target);
return result.OrderBy(f => Rank(order, f.ModId)).ToList();
}
private static int Rank(IReadOnlyList<string> order, string modId)
{
for (var i = 0; i < order.Count; i++)
{
if (order[i] == modId)
return i;
}
return order.Count;
}
// ── editing ────────────────────────────────────────────────
private void NewScene()
{
RunDiscardingChanges(StartNewScene);
}
private void StartNewScene()
{
_sceneName = string.Empty;
_isNew = true;
LoadTarget(_writableMods.FirstOrDefault());
_preview = null;
_sceneList.Select(null);
SetDirty();
SetStatus(ToolUi.Tr("tools/scene_editor/new_started"));
RefreshAll();
_nameEdit.GrabFocus();
}
private void OnNameChanged(string text)
{
if (_suppress || !_isNew || _target == null)
return;
_sceneName = SceneFileService.ValidName(text) ?? string.Empty;
_target.Name = _sceneName;
_target.RelativePath = SceneFileEntry.PathFor(_sceneName);
SetDirty();
}
private void OnModPicked(long index)
{
if (_suppress)
return;
var mod = index >= 0 && index < _writableMods.Count ? _writableMods[(int)index] : null;
if (_isNew)
{
if (_target != null)
_target.ModId = mod;
RefreshAll();
return;
}
_pendingMod = mod;
RunDiscardingChanges(SwitchTargetMod, RefreshAll);
}
private void SwitchTargetMod()
{
LoadTarget(_pendingMod);
MarkClean();
RefreshAll();
}
private void OnAmbientPicked(long index)
{
if (_suppress || _target == null)
return;
_target.Ambient = index > 0 ? _sounds[(int)index - 1] : string.Empty;
StopAmbientPreview();
SetDirty();
RefreshAll();
}
private void OnDefaultPicked(long index)
{
if (_suppress || _target == null)
return;
_target.DefaultScenario = index > 0 ? _defaultChoices[(int)index - 1] : string.Empty;
SetDirty();
RefreshAll();
}
private void ShowPreview(string background)
{
_preview = background;
RefreshPreview();
}
private void AddPreviewed() => AddBackground(_preview);
private void AddBackground(string background)
{
if (_target == null || string.IsNullOrEmpty(background))
return;
if (Merged().Any(m => m.Background == background))
{
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/already_there"), background));
return;
}
_target.Backgrounds.Add(background);
_preview = background;
SetDirty();
RefreshAll();
}
private void RemoveBackground(string background)
{
if (_target == null || !_target.Backgrounds.Remove(background))
return;
if (_preview == background)
_preview = Merged().FirstOrDefault().Background;
SetDirty();
RefreshAll();
}
// ── saving, deleting, going ────────────────────────────────
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
private void Save()
{
if (_target == null || !ModFileService.IsWritable(_target.ModId))
{
SetStatus(ToolUi.Tr("tools/scene_editor/no_writable_mod"), ToolStatusKind.Error);
return;
}
if (string.IsNullOrEmpty(_sceneName))
{
SetStatus(ToolUi.Tr("tools/scene_editor/bad_name"), ToolStatusKind.Error);
return;
}
if (_isNew && _files.Any(f => f.Name == _sceneName))
{
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/name_taken"), _sceneName), ToolStatusKind.Error);
return;
}
if (!Merged().Any())
{
SetStatus(ToolUi.Tr("tools/scene_editor/need_background"), ToolStatusKind.Error);
return;
}
if (!SceneFileService.Write(_target))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
var created = _isNew;
SceneFileService.RefreshGame();
var name = _sceneName;
var mod = _target.ModId;
LoadSources();
_sceneName = name;
_isNew = false;
LoadTarget(mod);
MarkClean();
_sceneList.Select(name);
RefreshAll();
SetStatus(string.Format(ToolUi.Tr(created ? "tools/scene_editor/created" : "tools/common/saved"),
created ? name : mod + "/" + _target.RelativePath), ToolStatusKind.Ok);
}
private void AskDelete()
{
if (_target == null || _isNew || !FilesOf(_sceneName).Any(f => f.ModId == _target.ModId))
return;
ShowConfirm("tools/scene_editor/delete", "tools/scene_editor/delete_confirm", Delete);
}
private void Delete()
{
if (!SceneFileService.Delete(_target))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
var path = _target.ModId + "/" + _target.RelativePath;
SceneFileService.RefreshGame();
LoadSources();
if (FilesOf(_sceneName).Any())
{
OpenScene(_sceneName);
}
else
{
_sceneName = null;
_target = null;
MarkClean();
RefreshAll();
}
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/deleted"), path), ToolStatusKind.Ok);
}
private void GoToScene()
{
if (_isNew || string.IsNullOrEmpty(_sceneName))
return;
var manager = GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
if (manager == null || !manager.HasScene(_sceneName))
{
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/go_failed"), _sceneName), ToolStatusKind.Error);
return;
}
StopAmbientPreview();
// Like the studio's teleport: a dialog left running would keep the box, and the
// entered scene's own scenario would wait behind it (or the old one would go on).
SceneContextNode.FindNode<DialogBox>("DialogBox")?.Abort();
manager.SwitchScene(_sceneName);
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/went"), _sceneName), ToolStatusKind.Ok);
}
private void Rescan()
{
RunDiscardingChanges(RescanNow);
}
private void RescanNow()
{
var name = _isNew ? null : _sceneName;
LoadSources();
if (name != null && FilesOf(name).Any())
OpenScene(name);
else
RefreshAll();
SetStatus(ToolUi.Tr("tools/scene_editor/rescanned"));
}
// ── scenarios ──────────────────────────────────────────────
private void NewScenario()
{
if (_target == null || _isNew || string.IsNullOrEmpty(_sceneName))
return;
var key = ScenarioFileService.CreateForScene(_target.ModId, _sceneName, NewLine());
if (key == null)
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
SceneFileService.RefreshGame();
RefreshAll();
OpenScenarioEditor(key);
SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/scenario_created"), key), ToolStatusKind.Ok);
}
private void OnScenarioActivated(long index) => OpenSelectedScenario();
private void OpenSelectedScenario()
{
var selected = _scenarioList.GetSelectedItems();
if (selected.Length == 0 || selected[0] >= _scenarioKeys.Count)
return;
OpenScenarioEditor(_scenarioKeys[selected[0]]);
}
private static void OpenScenarioEditor(string key)
{
var window = new ScenarioEditorWindow();
window.OpenScenario(key);
ShowWindow(window);
}
private static string NewLine() => "text \"" + ToolUi.Tr("tools/scene_editor/new_line_text").Replace("\"", "\\\"") + "\"";
// ── ambient preview ────────────────────────────────────────
private void ToggleAmbientPreview()
{
if (_ambientPlaying)
{
StopAmbientPreview();
RefreshAll();
return;
}
var ambient = EffectiveAmbient().Ambient;
var audio = GetTree().Root.GetNodeOrNull<Node>("AudioService") as IAudioService;
if (audio == null || string.IsNullOrEmpty(ambient))
return;
audio.PlayAmbient(ambient);
_ambientPlaying = true;
RefreshAll();
}
private void StopAmbientPreview()
{
if (!_ambientPlaying)
return;
_ambientPlaying = false;
(GetTree()?.Root.GetNodeOrNull<Node>("AudioService") as IAudioService)?.StopAmbient();
}
/// <summary>Stops a playing ambient preview when the window closes.</summary>
protected override void Cleanup() => StopAmbientPreview();
// ── refresh ────────────────────────────────────────────────
/// <summary>The ambient the game will play: the last non-empty one in load order, and the mod it comes from.</summary>
private (string Ambient, string ModId) EffectiveAmbient()
{
var file = MergedFiles().LastOrDefault(f => !string.IsNullOrEmpty(f.Ambient));
return (file?.Ambient ?? string.Empty, file?.ModId);
}
private (string Scenario, string ModId) EffectiveDefault()
{
var file = MergedFiles().LastOrDefault(f => !string.IsNullOrEmpty(f.DefaultScenario));
return (file?.DefaultScenario ?? string.Empty, file?.ModId);
}
private void RefreshAll()
{
_suppress = true;
var hasScene = _target != null && (_isNew || !string.IsNullOrEmpty(_sceneName));
_nameEdit.Text = _sceneName ?? string.Empty;
_nameEdit.Editable = _isNew;
_modSelect.Disabled = !hasScene;
_modSelect.Select(_target == null ? -1 : _writableMods.IndexOf(_target.ModId));
_modHint.Text = ModHint();
_ambientSelect.Disabled = !hasScene;
_ambientSelect.Select(_target == null ? 0 : _sounds.IndexOf(_target.Ambient) + 1);
var ambient = EffectiveAmbient();
_ambientHint.Text = ambient.ModId != null && ambient.ModId != _target?.ModId
? string.Format(ToolUi.Tr("tools/scene_editor/effective"), ambient.Ambient, ambient.ModId)
: string.Empty;
_ambientPlayBtn.Text = ToolUi.Tr(_ambientPlaying ? "tools/scene_editor/stop" : "tools/scene_editor/listen");
_ambientPlayBtn.Disabled = string.IsNullOrEmpty(ambient.Ambient) && !_ambientPlaying;
RefreshScenarios(hasScene);
RefreshBackgrounds();
RefreshPreview();
_deleteBtn.Disabled = _target == null || _isNew || !FilesOf(_sceneName).Any(f => f.ModId == _target.ModId);
_goBtn.Disabled = _isNew || string.IsNullOrEmpty(_sceneName);
_suppress = false;
}
private string ModHint()
{
if (_writableMods.Count == 0)
return ToolUi.Tr("tools/scene_editor/no_writable_mod");
if (_target == null || _isNew)
return string.Empty;
var others = FilesOf(_sceneName).Select(f => f.ModId).Where(m => m != _target.ModId).Distinct().ToList();
var own = FilesOf(_sceneName).Any(f => f.ModId == _target.ModId);
if (!own)
return string.Format(ToolUi.Tr("tools/scene_editor/adds_to"), string.Join(", ", others));
return others.Count > 0 ? string.Format(ToolUi.Tr("tools/scene_editor/also_in"), string.Join(", ", others)) : string.Empty;
}
private void RefreshScenarios(bool hasScene)
{
_scenarioKeys.Clear();
_defaultChoices.Clear();
_scenarioList.Clear();
if (hasScene && !_isNew)
{
foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files))
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
if (text == null || ScenarioFileService.ParseMetadata(text).Scene != _sceneName)
continue;
_scenarioKeys.Add(file.Key);
_scenarioList.AddItem(file.ModId + ": " + file.RelativePath);
if (!_defaultChoices.Contains(file.RelativePath))
_defaultChoices.Add(file.RelativePath);
}
}
if (_target != null && !string.IsNullOrEmpty(_target.DefaultScenario) && !_defaultChoices.Contains(_target.DefaultScenario))
_defaultChoices.Add(_target.DefaultScenario);
_defaultSelect.Clear();
_defaultSelect.AddItem(ToolUi.Tr("tools/scene_editor/none"));
foreach (var path in _defaultChoices)
_defaultSelect.AddItem(path);
_defaultSelect.Select(_target == null ? 0 : _defaultChoices.IndexOf(_target.DefaultScenario) + 1);
_defaultSelect.Disabled = !hasScene || _isNew;
var effective = EffectiveDefault();
_defaultHint.Text = effective.ModId != null && effective.ModId != _target?.ModId
? string.Format(ToolUi.Tr("tools/scene_editor/effective"), effective.Scenario, effective.ModId)
: string.Empty;
if (hasScene && !_isNew && _scenarioKeys.Count == 0)
_scenarioList.AddItem(ToolUi.Tr("tools/scene_editor/no_scenarios"), null, false);
_openScenarioBtn.Disabled = _scenarioKeys.Count == 0;
_newScenarioBtn.Disabled = !hasScene || _isNew || !ModFileService.IsWritable(_target?.ModId);
}
private void RefreshBackgrounds()
{
foreach (var child in _backgroundRow.GetChildren())
{
_backgroundRow.RemoveChild(child);
child.QueueFree();
}
var any = false;
foreach (var (background, file) in Merged())
{
any = true;
_backgroundRow.AddChild(new SceneBackgroundTile(background, file.ModId, file == _target, background == _preview,
ShowPreview, RemoveBackground));
}
if (!any)
_backgroundRow.AddChild(ToolUi.Hint(ToolUi.Tr(_target == null ? "tools/scene_editor/pick_scene" : "tools/scene_editor/no_backgrounds")));
}
private void RefreshPreview()
{
_previewRect.Texture = string.IsNullOrEmpty(_preview) ? null : ModResourceService.LoadBackgroundTexture(_preview);
_previewCaption.Text = string.IsNullOrEmpty(_preview)
? ToolUi.Tr("tools/scene_editor/preview_hint")
: _preview + (Merged().Any(m => m.Background == _preview) ? string.Empty : " " + ToolUi.Tr("tools/scene_editor/not_in_scene"));
if (_addBackgroundBtn != null)
_addBackgroundBtn.Disabled = _target == null || string.IsNullOrEmpty(_preview) || Merged().Any(m => m.Background == _preview);
}
internal static Vector2 ThumbSize => new(ThumbWidth, ThumbHeight);
}
}
+19
View File
@@ -46,6 +46,25 @@ namespace Cthangover.Tools.Services
return ids;
}
/// <summary>Ids of every sound (group <c>sounds</c>, path without extension): the choices for a scene's ambient.</summary>
public static List<string> GetSoundIds()
{
var ids = new List<string>();
var exts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var ext in ModConfig.Instance.AudioExtensions)
exts.Add(ext);
foreach (var id in Files.Collect("sounds").Keys)
{
var ext = Path.GetExtension(id);
if (exts.Contains(ext))
ids.Add(id.Substring(0, id.Length - ext.Length));
}
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
private static HashSet<string> GetExt()
{
var set = new HashSet<string>();
+15
View File
@@ -277,6 +277,21 @@ namespace Cthangover.Tools.Services
return true;
}
/// <summary>
/// Creates <c>scenarios/&lt;scene&gt;[_n].scenario</c> in a folder mod: a header bound to
/// <paramref name="scene"/> and one line of text to replace. Returns the file key, or
/// null when the mod is not writable.
/// </summary>
public static string CreateForScene(string modId, string scene, string firstLine)
{
var path = $"{ScenariosDir}/{scene}{ScenarioExtension}";
for (var n = 2; ModManager.Instance.ReadFileText(modId, path) != null; n++)
path = $"{ScenariosDir}/{scene}_{n}{ScenarioExtension}";
var text = $"scene: {scene}\npriority: 10\n---\n{firstLine}\nend\n";
return ModFileService.WriteText(modId, path, text) ? MakeKey(modId, path) : null;
}
/// <summary>Absolute filesystem path of a scenario file, or <c>null</c> for zip-backed mods.</summary>
public static string GetAbsolutePath(string modId, string relativePath)
{
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
namespace Cthangover.Tools.Services
{
/// <summary>
/// One mod's file of a logical scene (<c>scenes/&lt;name&gt;.json</c>). The game merges
/// the files of all mods into one <see cref="SceneDefinition"/> (backgrounds add up,
/// a later non-empty ambient or default scenario wins); the scene editor works on
/// the files themselves, because only one of them — the chosen mod's — is written.
/// </summary>
public sealed class SceneFileEntry
{
/// <summary>Mod that owns the file.</summary>
public string ModId { get; set; }
/// <summary>Mod-relative path of the file.</summary>
public string RelativePath { get; set; }
/// <summary>Scene name (the logical scene id).</summary>
public string Name { get; set; }
/// <summary>Backgrounds this file adds; the game shows one of the merged list at random.</summary>
public List<string> Backgrounds { get; set; } = new();
/// <summary>Ambient sound id (group <c>sounds</c>), empty for none.</summary>
public string Ambient { get; set; } = string.Empty;
/// <summary>Mod-relative path of the scenario played when nothing else starts, empty for none.</summary>
public string DefaultScenario { get; set; } = string.Empty;
/// <summary>Default path of a scene file: <c>scenes/&lt;name&gt;.json</c>.</summary>
public static string PathFor(string name) => "scenes/" + name + ".json";
/// <summary>Reads a scene file with the game's own JSON options; null when it is not a scene.</summary>
public static SceneFileEntry Parse(string modId, string relativePath, string json)
{
SceneDefinition definition;
try
{
definition = JsonSerializer.Deserialize<SceneDefinition>(json ?? string.Empty, JsonOptionsProvider.ReadOptions);
}
catch (JsonException)
{
return null;
}
if (string.IsNullOrEmpty(definition?.Name))
return null;
return new SceneFileEntry
{
ModId = modId,
RelativePath = relativePath,
Name = definition.Name,
Backgrounds = definition.DefaultBackground ?? new List<string>(),
Ambient = definition.DefaultAmbient ?? string.Empty,
DefaultScenario = definition.DefaultScenario ?? string.Empty,
};
}
/// <summary>
/// The file text in the style of the hand-written scene files (two-space indent, LF,
/// camelCase keys). Empty fields are left out, so a file of a mod that only adds a
/// background does not reset another mod's ambient or default scenario.
/// </summary>
public string ToJson()
{
var json = new StringBuilder("{\n \"name\": ").Append(Quote(Name));
if (Backgrounds.Count > 0)
{
json.Append(",\n \"defaultBackground\": [");
for (var i = 0; i < Backgrounds.Count; i++)
json.Append(i == 0 ? string.Empty : ", ").Append(Quote(Backgrounds[i]));
json.Append(']');
}
if (!string.IsNullOrEmpty(Ambient))
json.Append(",\n \"defaultAmbient\": ").Append(Quote(Ambient));
if (!string.IsNullOrEmpty(DefaultScenario))
json.Append(",\n \"defaultScenario\": ").Append(Quote(DefaultScenario));
return json.Append("\n}\n").ToString();
}
private static string Quote(string value) => JsonSerializer.Serialize(value ?? string.Empty);
}
}
+92
View File
@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Scene files (<c>scenes/**.json</c>) of the loaded mods for the scene editor: reading
/// them per mod in load order (the order the game merges them in), writing one, and
/// making the running game see the change at once. The game caches scene definitions
/// and scenario headers; <see cref="RefreshGame"/> drops those caches and lets
/// <see cref="SceneManager"/> rescan, the same steps the scenario studio takes after
/// creating a scene in its layer, so a new scene can be entered without a mod reload.
/// </summary>
public static class SceneFileService
{
private const string ScenesDir = "scenes";
private static readonly Regex NamePattern = new("^[a-z][a-z0-9_]*$");
/// <summary>Every scene file of the loaded mods and of the studio layer, in load order.</summary>
public static List<SceneFileEntry> Scan()
{
var result = new List<SceneFileEntry>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var modId in ModRegistry.Instance.GetOrderedModIds())
{
if (manager.Mods.TryGetValue(modId, out var mod) && mod?.FileProvider != null)
Collect(mod.FileProvider, modId, ScenesDir, result);
}
foreach (var overlay in ModOverlays.All)
Collect(overlay.Value, overlay.Key, ScenesDir, result);
return result;
}
/// <summary>The scene name typed by the author, trimmed and lowercased; null when it is not an id (latin letters, digits, _).</summary>
public static string ValidName(string name)
{
name = (name ?? string.Empty).Trim().ToLowerInvariant();
return NamePattern.IsMatch(name) ? name : null;
}
/// <summary>Writes the entry to its mod; false when the mod is not a folder mod.</summary>
public static bool Write(SceneFileEntry entry)
{
return ModFileService.WriteText(entry.ModId, entry.RelativePath, entry.ToJson());
}
/// <summary>Deletes the entry's file; false when the mod is not a folder mod or the file is gone.</summary>
public static bool Delete(SceneFileEntry entry)
{
var path = ModFileService.GetWritablePath(entry.ModId, entry.RelativePath);
if (path == null || !File.Exists(path))
return false;
File.Delete(path);
return true;
}
/// <summary>Makes the running game re-read scene files and scenario headers.</summary>
public static void RefreshGame()
{
ScenarioDefs.Invalidate();
ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.ClearCache();
AppServices.Current.SceneDefs.Clear();
SceneManager.RescanScenes();
}
private static void Collect(IModFileProvider provider, string modId, string directory, List<SceneFileEntry> result)
{
foreach (var entry in provider.ListFiles(directory))
{
if (entry.EndsWith("/"))
{
Collect(provider, modId, entry.TrimEnd('/'), result);
continue;
}
if (!entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
continue;
var scene = SceneFileEntry.Parse(modId, entry, provider.ReadFileText(entry));
if (scene != null)
result.Add(scene);
}
}
}
}