This commit is contained in:
cthangover committed 2026-10-19 20:21:01 +03:00
commit 7a088b25a9
58 files changed
+6053

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
namespace Cthangover.Mods.ScenarioStudio
{
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Log" tab: what happened, newest last — scene entries, skipped scenarios
/// with the reason, scenario starts and ends, stops and studio edits.
/// Skips are tinted as warnings because they are the usual answer to
/// "nothing happened".
/// </summary>
public static class StudioLogPage
{
private const int TimeWidth = 44;
public static void Build(VBoxContainer content, StudioSession session)
{
var list = UiKit.Column(0);
content.AddChild(list);
foreach (var entry in session.Log)
{
var plate = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
plate.AddThemeStyleboxOverride("panel", StudioStyles.LogRow(entry.IsWarning));
var row = UiKit.Row(UiTokens.Space3);
plate.AddChild(row);
var time = StudioStyles.Text(entry.Time, UiTokens.FontSmall, UiTokens.Faint, mono: true);
time.CustomMinimumSize = new Vector2(TimeWidth, 0);
row.AddChild(time);
row.AddChild(UiKit.Badge(TranslationServer.Translate(CategoryKey(entry.Category)), StudioStyles.LogColor(entry.Category)));
var text = UiKit.Label(entry.Text.Resolve(), UiTokens.FontBodySmall, entry.IsWarning ? UiTokens.Warning : UiTokens.Body);
text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
text.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(text);
list.AddChild(plate);
}
}
private static string CategoryKey(StudioLogCategory category)
{
switch (category)
{
case StudioLogCategory.Scene:
return "studio/log_scene";
case StudioLogCategory.Skip:
return "studio/log_skip";
case StudioLogCategory.Stop:
return "studio/log_stop";
case StudioLogCategory.End:
return "studio/log_end";
case StudioLogCategory.Edit:
return "studio/log_edit";
default:
return "studio/log_scenario";
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Map" tab: the walk between scenes around the player, laid out as a
/// vertical path because the dock is narrow — where the player came from,
/// where they are, and the exits from here with the command or choice that
/// leads there. Exits can be taken directly (a debugging teleport); new
/// scenes are created in the studio layer, the current scenario can be
/// linked to any scene, and the layer can be exported as a regular mod.
/// </summary>
public static class StudioMapPage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> go, Action<StudioActionButton> newScene, Action<StudioActionButton> link, Action<StudioActionButton> export)
{
var visited = false;
foreach (var node in session.Nodes)
{
if (node.State != StudioNodeState.Visited)
continue;
if (!visited)
content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_from"), UiTokens.FontSmall));
visited = true;
content.AddChild(BuildNode(node, go));
content.AddChild(Arrow());
}
if (!visited)
content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_here"), UiTokens.FontSmall));
foreach (var node in session.Nodes)
{
if (node.State == StudioNodeState.Here)
content.AddChild(BuildNode(node, null));
}
content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_to"), UiTokens.FontSmall));
var exits = 0;
foreach (var node in session.Nodes)
{
if (node.State != StudioNodeState.Next && node.State != StudioNodeState.New)
continue;
content.AddChild(BuildNode(node, go));
exits++;
}
if (exits == 0)
content.AddChild(UiKit.Hint(TranslationServer.Translate("studio/map_no_exits"), UiTokens.FontBodySmall));
var actions = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
actions.AddThemeConstantOverride("h_separation", UiTokens.Space2);
actions.AddThemeConstantOverride("v_separation", UiTokens.Space2);
actions.AddChild(StudioActionButton.Create("studio/map_new_scene", newScene));
if (session.HasScenario)
actions.AddChild(StudioActionButton.Create("studio/map_link", link));
actions.AddChild(StudioActionButton.Create("studio/map_export", export));
content.AddChild(actions);
}
private static Control BuildNode(StudioSceneNode node, Action<StudioActionButton> go)
{
var card = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
card.AddThemeStyleboxOverride("panel", StudioStyles.Node(node.State));
var row = UiKit.Row(UiTokens.Space3);
card.AddChild(row);
var column = UiKit.Column(UiTokens.Space1);
column.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(column);
var head = UiKit.Row(UiTokens.Space3);
var nameColor = node.State == StudioNodeState.Here ? UiTokens.AccentBright : UiTokens.Ink;
head.AddChild(StudioStyles.Text(node.Name, UiTokens.FontBodySmall, nameColor, mono: true));
head.AddChild(StudioStyles.Scope(node.Scope));
column.AddChild(head);
var sub = TranslationServer.Translate(StateKey(node.State));
if (node.Via != null)
sub += " · " + node.Via.Resolve();
column.AddChild(StudioStyles.Text(sub, UiTokens.FontSmall, UiTokens.Faint));
if (node.State != StudioNodeState.Here)
{
var button = StudioActionButton.Create("studio/map_go", go);
button.Payload = node.Name;
button.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(button);
}
return card;
}
private static Control Arrow()
{
var arrow = StudioStyles.Text("↓", UiTokens.FontBody, UiTokens.Faint);
arrow.HorizontalAlignment = HorizontalAlignment.Center;
return arrow;
}
private static string StateKey(StudioNodeState state)
{
switch (state)
{
case StudioNodeState.Here:
return "studio/node_here";
case StudioNodeState.Visited:
return "studio/node_visited";
case StudioNodeState.New:
return "studio/node_new";
default:
return "studio/node_next";
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Now" tab in the novice mode: the moment of the story rather than the
/// script (<see cref="StudioMoment"/>) — what is on screen, the lines said
/// right before it, every answer with where it leads, then what else the
/// scene has queued. The only insert actions are the ones that make sense
/// here: a line before the question (or after a plain line) and a new
/// answer, which starts broken until its target is chosen. Requests carry
/// 0-based indices into the scenario text, where the dock inserts. Ends
/// with the same "this change affects" plate as the author mode.
/// </summary>
public sealed class StudioNovicePage
{
/// <summary>A new line is requested at the given text index.</summary>
public event Action<int> LineRequested;
/// <summary>A new answer is requested at the given text index.</summary>
public event Action<int> OptionRequested;
/// <summary>"Edit" on a line, question or answer.</summary>
public event Action<StudioActionButton> EditRequested;
/// <summary>"Choose where" on an answer.</summary>
public event Action<StudioActionButton> TargetRequested;
private StudioMoment _moment;
public void Build(VBoxContainer content, StudioSession session)
{
_moment = StudioMoment.Build(session);
if (_moment.Say == null)
{
content.AddChild(UiKit.EmptyState(TranslationServer.Translate(session.HasScenario ? "studio/moment_empty" : "studio/no_scenario"), UiTokens.FontBody));
return;
}
content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_now")));
foreach (var line in _moment.Before)
content.AddChild(new StudioSayView(line, true, OnEdit));
content.AddChild(new StudioSayView(_moment.Say, false, OnEdit));
if (_moment.IsQuestion)
{
content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_answers")));
foreach (var branch in _moment.Branches)
content.AddChild(new StudioBranchView(branch, OnEdit, OnTarget));
}
var actions = UiKit.Row(UiTokens.Space2);
actions.AddChild(InsertButton(_moment.IsQuestion ? "studio/add_line_before" : "studio/add_line_after", OnLinePressed));
if (_moment.IsQuestion)
actions.AddChild(InsertButton("studio/add_option", OnOptionPressed));
content.AddChild(actions);
content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_next")));
content.AddChild(BuildNext(session));
content.AddChild(StudioNowPage.BuildScopeBanner(session));
}
private static Control BuildNext(StudioSession session)
{
foreach (var candidate in session.Candidates)
{
if (candidate.Status == StudioCandidateStatus.Queued)
return UiKit.Hint(string.Format(TranslationServer.Translate("studio/next_waiting"), candidate.Name), UiTokens.FontBodySmall);
}
return UiKit.Hint(TranslationServer.Translate("studio/next_none"), UiTokens.FontBodySmall);
}
private static Button InsertButton(string key, Action handler)
{
var button = StudioStyles.SmallButton(new Button(), TranslationServer.Translate(key));
button.Pressed += handler;
return button;
}
private void OnLinePressed()
{
var say = _moment.Say.Number - 1;
LineRequested?.Invoke(_moment.IsQuestion ? say : say + 1);
}
private void OnOptionPressed()
{
var last = _moment.Branches.Count > 0 ? _moment.Branches[^1].Line : _moment.Say;
OptionRequested?.Invoke(last.Number);
}
private void OnEdit(StudioActionButton button) => EditRequested?.Invoke(button);
private void OnTarget(StudioActionButton button) => TargetRequested?.Invoke(button);
}
}
+197
View File
@@ -0,0 +1,197 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Now" tab in the author mode: the running scenario as script with a
/// gutter (<see cref="StudioCodeLineView"/>) and the insert bar for a line
/// after the current one. It ends with the "this change affects" plate
/// naming the mod that receives the edit and the mod it overrides, so
/// nobody changes someone else's content unknowingly; the novice mode
/// (<see cref="StudioNovicePage"/>) shows the same plate.
/// <para/>
/// Dragging the ▶ marker of the current line highlights the line under the
/// mouse when the running dialog can continue from it
/// (<see cref="StudioCodeLine.CanRunNext"/>) and on release asks for it
/// with <see cref="NextLineRequested"/>; a drop anywhere else does nothing.
/// <para/>
/// One instance lives as long as the dock; <see cref="Build"/> refills the
/// body and wires the controls to this instance's handlers.
/// </summary>
public sealed class StudioNowPage
{
/// <summary>A gutter click changed a breakpoint.</summary>
public event Action<StudioCodeLine> BreakpointToggled;
/// <summary>One of the insert buttons was pressed.</summary>
public event Action<StudioLineKind> InsertRequested;
/// <summary>The ▶ marker was dropped on a line the dialog can continue from.</summary>
public event Action<StudioCodeLine> NextLineRequested;
private readonly List<StudioCodeLineView> _views = new();
private StudioCodeLineView _dropTarget;
private bool _dragging;
/// <summary>True while the ▶ marker is dragged: a redraw would drop the drag.</summary>
public bool IsDragging => _dragging;
public void Build(VBoxContainer content, StudioSession session)
{
_views.Clear();
_dropTarget = null;
_dragging = false;
if (!session.HasScenario)
{
content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/no_scenario"), UiTokens.FontBody));
return;
}
var well = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
well.AddThemeStyleboxOverride("panel", StudioStyles.CodeWell());
content.AddChild(well);
var lines = UiKit.Column(0);
well.AddChild(lines);
foreach (var line in session.Lines)
{
var view = new StudioCodeLineView(line);
view.BreakpointToggled += OnBreakpointToggled;
view.MarkerDragged += OnMarkerDragged;
view.MarkerDropped += OnMarkerDropped;
lines.AddChild(view);
_views.Add(view);
}
var at = session.InsertionTextIndexAfterCurrent();
var caption = at <= 0 ? string.Empty : string.Format(TranslationServer.Translate("studio/after_line"), at);
content.AddChild(BuildInsertBar(caption));
content.AddChild(BuildScopeBanner(session));
}
private Control BuildInsertBar(string caption)
{
var bar = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
bar.AddThemeStyleboxOverride("panel", StudioStyles.InsertBar());
var row = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("h_separation", UiTokens.Space2);
row.AddThemeConstantOverride("v_separation", UiTokens.Space2);
bar.AddChild(row);
if (!string.IsNullOrEmpty(caption))
{
var label = StudioStyles.Text(caption, UiTokens.FontSmall, UiTokens.Faint);
label.VerticalAlignment = VerticalAlignment.Center;
label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(label);
}
row.AddChild(InsertButton("studio/add_line", OnAddLine));
row.AddChild(InsertButton("studio/add_choice", OnAddChoice));
row.AddChild(InsertButton("studio/add_jump", OnAddJump));
return bar;
}
private static Button InsertButton(string key, Action handler)
{
var button = StudioStyles.SmallButton(new Button(), TranslationServer.Translate(key));
button.Pressed += handler;
return button;
}
/// <summary>
/// Inline editor of the whole scenario text (the footer's "Edit text"),
/// filling the body; the dock reads the text back when editing ends.
/// </summary>
public static CodeEdit BuildEditor(VBoxContainer content, string text)
{
var editor = new CodeEdit
{
Text = text ?? string.Empty,
GuttersDrawLineNumbers = true,
HighlightCurrentLine = true,
WrapMode = TextEdit.LineWrappingMode.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 560),
SyntaxHighlighter = StudioSyntax.CreateHighlighter(),
};
editor.AddThemeFontOverride("font", StudioStyles.Mono);
editor.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
content.AddChild(editor);
return editor;
}
/// <summary>"This change affects" plate: the edit scope and, when it differs, the mod it overrides.</summary>
public static Control BuildScopeBanner(StudioSession session)
{
var banner = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
banner.AddThemeStyleboxOverride("panel", StudioStyles.ScopeBanner());
var column = UiKit.Column(UiTokens.Space2);
banner.AddChild(column);
var title = StudioStyles.Text(TranslationServer.Translate("studio/affects"), UiTokens.FontTiny, UiTokens.Warning);
title.Uppercase = true;
column.AddChild(title);
var row = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("h_separation", UiTokens.Space3);
column.AddChild(row);
if (session.EditScope == null)
return banner;
row.AddChild(StudioStyles.Scope(session.EditScope));
if (session.EditScope.ModId != session.FileScope.ModId)
{
row.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/overrides"), UiTokens.FontSmall, UiTokens.Faint));
row.AddChild(StudioStyles.Scope(session.FileScope));
}
return banner;
}
private void OnBreakpointToggled(StudioCodeLine line) => BreakpointToggled?.Invoke(line);
private void OnMarkerDragged(Vector2 at)
{
_dragging = true;
var target = DropTargetAt(at);
if (target == _dropTarget)
return;
_dropTarget?.ShowDropTarget(false);
_dropTarget = target;
_dropTarget?.ShowDropTarget(true);
}
private void OnMarkerDropped(Vector2 at)
{
_dragging = false;
_dropTarget?.ShowDropTarget(false);
_dropTarget = null;
var target = DropTargetAt(at);
if (target != null)
NextLineRequested?.Invoke(target.Line);
}
/// <summary>The row under <paramref name="at"/> (canvas coordinates) the dialog can continue from, or null.</summary>
private StudioCodeLineView DropTargetAt(Vector2 at)
{
foreach (var view in _views)
{
if (view.Line.CanRunNext && view.GetGlobalRect().HasPoint(at))
return view;
}
return null;
}
private void OnAddLine() => InsertRequested?.Invoke(StudioLineKind.Line);
private void OnAddChoice() => InsertRequested?.Invoke(StudioLineKind.Choice);
private void OnAddJump() => InsertRequested?.Invoke(StudioLineKind.Jump);
}
}
+103
View File
@@ -0,0 +1,103 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Scene" tab: every scenario the last composition of the current scene
/// considered, in start order, with the owning mod, priority, header
/// condition and its result, and the status with the reason in words. This
/// is the answer to "why did my scenario not start": a false condition, a
/// one-run scenario already completed, or another mod winning the same
/// scenario name. Any candidate can be started now; a completed one can be
/// made fresh again; a new scenario for the scene goes to the studio layer.
/// </summary>
public static class StudioScenePage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> run, Action<StudioActionButton> reset, Action<StudioActionButton> create)
{
var hint = UiKit.Hint(TranslationServer.Translate("studio/cand_hint"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
content.AddChild(hint);
if (session.Candidates.Count == 0)
content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/cand_none"), UiTokens.FontBody));
foreach (var candidate in session.Candidates)
content.AddChild(BuildCard(candidate, run, reset));
var add = StudioActionButton.Create("studio/new_scenario", create);
add.Payload = session.SceneName;
add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin;
content.AddChild(add);
}
private static Control BuildCard(StudioCandidate candidate, Action<StudioActionButton> run, Action<StudioActionButton> reset)
{
var running = candidate.Status == StudioCandidateStatus.Running;
var card = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
card.AddThemeStyleboxOverride("panel", StudioStyles.Candidate(running));
var column = UiKit.Column(UiTokens.Space2);
card.AddChild(column);
var head = UiKit.Row(UiTokens.Space3);
head.AddChild(StudioStyles.Text(candidate.Name, UiTokens.FontBody, UiTokens.InkBright, mono: true));
head.AddChild(UiKit.Spacer());
head.AddChild(UiKit.Badge(TranslationServer.Translate(StatusKey(candidate.Status)), StudioStyles.StatusColor(candidate.Status)));
column.AddChild(head);
var meta = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
meta.AddThemeConstantOverride("h_separation", UiTokens.Space3);
meta.AddChild(StudioStyles.Scope(candidate.Scope));
meta.AddChild(StudioStyles.Text(string.Format(TranslationServer.Translate("studio/priority"), candidate.Priority), UiTokens.FontSmall, UiTokens.Muted, mono: true));
var condition = candidate.Condition ?? TranslationServer.Translate("studio/no_condition");
var mark = candidate.ConditionOk == null ? "" : candidate.ConditionOk.Value ? "✓ " : "✗ ";
var conditionColor = candidate.ConditionOk == false ? UiTokens.DangerText : candidate.ConditionOk == true ? UiTokens.SuccessText : UiTokens.Muted;
meta.AddChild(StudioStyles.Text(mark + condition, UiTokens.FontSmall, conditionColor, mono: true));
if (candidate.IsOneRun)
meta.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/one_run"), UiTokens.FontSmall, UiTokens.Muted));
column.AddChild(meta);
var reason = UiKit.Label(candidate.Reason?.Resolve() ?? string.Empty, UiTokens.FontSmall, UiTokens.Muted);
reason.AutowrapMode = TextServer.AutowrapMode.WordSmart;
column.AddChild(reason);
if (running || candidate.Status == StudioCandidateStatus.Shadowed)
return card;
var actions = UiKit.Row(UiTokens.Space2);
var start = StudioActionButton.Create("studio/run_now", run);
start.Payload = candidate.Name;
actions.AddChild(start);
if (candidate.Status == StudioCandidateStatus.Completed)
{
var fresh = StudioActionButton.Create("studio/reset_completed", reset);
fresh.Payload = candidate.Name;
actions.AddChild(fresh);
}
column.AddChild(actions);
return card;
}
private static string StatusKey(StudioCandidateStatus status)
{
switch (status)
{
case StudioCandidateStatus.Running:
return "studio/cand_running";
case StudioCandidateStatus.Queued:
return "studio/cand_queued";
case StudioCandidateStatus.Skipped:
return "studio/cand_skipped";
case StudioCandidateStatus.Completed:
return "studio/cand_completed";
case StudioCandidateStatus.Failed:
return "studio/cand_failed";
default:
return "studio/cand_shadowed";
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "State" tab: game state grouped by owner — time, dialog variables of the
/// running dialog, quests the current scenario refers to, inventory and the
/// one-run completion history. Variables, item counts and quest status are
/// editable (Enter writes them into the game), because changing one is the
/// quickest way to test another branch of a scenario.
/// </summary>
public static class StudioStatePage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioStateEntry, string> submitted)
{
var hint = UiKit.Hint(TranslationServer.Translate("studio/state_hint"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
content.AddChild(hint);
string group = null;
VBoxContainer rows = null;
foreach (var entry in session.State)
{
if (entry.Group.Key != group)
{
group = entry.Group.Key;
content.AddChild(UiKit.SectionTitle(entry.Group.Resolve(), UiTokens.FontSmall));
var well = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
well.AddThemeStyleboxOverride("panel", StudioStyles.Well());
content.AddChild(well);
rows = UiKit.Column(UiTokens.Space2);
well.AddChild(rows);
}
var row = new StudioStateRow(entry);
row.Submitted += submitted;
rows.AddChild(row);
}
}
}
}