Update characters_editor implementation, assets and documentation

This commit is contained in:
cthangover committed 2026-10-31 15:28:49 +03:00
1 parent c975b5ae86
commit d7250b26b2
28 files changed
+429 -73

No files matched your search

+126 -3
View File
@@ -1,10 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Globalization;
using System.Text.Json.Nodes;
using Cthangover.Core.Characters;
using Cthangover.Core.Games;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Settings;
using Cthangover.Mods.Stats;
using Cthangover.Tools.Common;
using Cthangover.Tools.EntityEditor;
using Godot;
namespace Cthangover.Tools.CharactersEditor
{
@@ -17,6 +23,15 @@ namespace Cthangover.Tools.CharactersEditor
/// battle actions offered from the loaded <c>action</c> entities. Loot and
/// other mods' fields are "other fields" of the base window. New characters go
/// to <c>characters/&lt;mod&gt;.json</c>.
/// <para>
/// Plan 12.5: the panel under the form marks the party leader (the character
/// whose <c>Charset</c> the player walks with on RPG maps: the game setting
/// <c>stats.player</c>, else the first battle-set member, as <c>RpgPlayerLook</c>
/// reads it) and shows the values a character's <c>Stats</c> compute. A character
/// with <c>Stats</c> gets its health, attack and defence from them, so those three
/// fields are read-only here (validator rule <c>STAT_FIELD_OVERRIDDEN</c>, plan 6.4).
/// Setting the leader is the game settings' job (mod manager), not a button here.
/// </para>
/// </summary>
public partial class CharacterEditorWindow : EntityEditorWindow
{
@@ -31,9 +46,9 @@ namespace Cthangover.Tools.CharactersEditor
new EntityField { Key = "Image", LabelKey = "tools/characters_editor/image", TooltipKey = "tools/characters_editor/image_tip", Kind = EntityFieldKind.Picture, Group = "characters" },
new EntityField { Key = "Charset", LabelKey = "tools/characters_editor/charset", TooltipKey = "tools/characters_editor/charset_tip", Kind = EntityFieldKind.Picture, Group = "charsets" },
new EntityField { Key = "Level", LabelKey = "tools/characters_editor/level", Kind = EntityFieldKind.Integer },
new EntityField { Key = "Health", LabelKey = "tools/characters_editor/health", Kind = EntityFieldKind.Integer },
new EntityField { Key = "Attack", LabelKey = "tools/characters_editor/attack", Kind = EntityFieldKind.Integer },
new EntityField { Key = "Defence", LabelKey = "tools/characters_editor/defence", Kind = EntityFieldKind.Integer },
new EntityField { Key = "Health", LabelKey = "tools/characters_editor/health", Kind = EntityFieldKind.Integer, Locked = HasStats },
new EntityField { Key = "Attack", LabelKey = "tools/characters_editor/attack", Kind = EntityFieldKind.Integer, Locked = HasStats },
new EntityField { Key = "Defence", LabelKey = "tools/characters_editor/defence", Kind = EntityFieldKind.Integer, Locked = HasStats },
new EntityField { Key = "Point", LabelKey = "tools/characters_editor/point", TooltipKey = "tools/characters_editor/point_tip", Kind = EntityFieldKind.Integer },
new EntityField { Key = "Exp", LabelKey = "tools/characters_editor/exp", TooltipKey = "tools/characters_editor/exp_tip", Kind = EntityFieldKind.Integer },
new EntityField { Key = "RecruitmentChance", LabelKey = "tools/characters_editor/recruitment", TooltipKey = "tools/characters_editor/recruitment_tip", Kind = EntityFieldKind.Integer },
@@ -44,6 +59,114 @@ namespace Cthangover.Tools.CharactersEditor
private static IEnumerable<string> Actions()
=> (Entities.Collect<ActionCharacterInfo>()?.Keys ?? Enumerable.Empty<string>()).OrderBy(id => id, StringComparer.Ordinal);
/// <summary>A character with <c>Stats</c> gets its bound battle numbers from the stats (see the class summary).</summary>
private static bool HasStats(JsonObject data) => data["Stats"] != null;
/// <summary>The party leader's id, as <c>RpgPlayerLook.LeaderId</c> reads it; null without one.</summary>
public static string LeaderId()
{
var setting = ActiveGame.GetSetting(PlayerSetting);
if (!string.IsNullOrWhiteSpace(setting))
return setting.Trim();
var roster = GameData.Instance?.Runtime?.CharacterData;
return roster?.Characters.Keys.FirstOrDefault(id => roster.BattleSet.Contains(id));
}
private const string PlayerSetting = "stats.player";
// Created by CreatePreviewBlock, which the base window calls while it builds (before this class's constructor body): no initialisers.
private Label leaderLabel;
private VBoxContainer computedBox;
private CheckBox joinBox;
private string shownId;
/// <summary>The party section and the computed battle numbers, below the fields.</summary>
protected override Control CreatePreviewBlock()
{
var block = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
block.AddChild(ToolUi.Section("tools/characters_editor/section_party"));
leaderLabel = ToolUi.Hint(string.Empty);
leaderLabel.AutowrapMode = TextServer.AutowrapMode.WordSmart;
block.AddChild(leaderLabel);
joinBox = new CheckBox { Name = "JoinsParty", Text = ToolUi.Tr("tools/characters_editor/join_check"), TooltipText = ToolUi.Tr("tools/characters_editor/join_tip") };
joinBox.Toggled += OnJoinToggled;
block.AddChild(joinBox);
var joinHint = ToolUi.Hint(ToolUi.Tr("tools/characters_editor/join_hint"));
joinHint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
block.AddChild(joinHint);
block.AddChild(ToolUi.Section("tools/characters_editor/section_computed"));
block.AddChild(ToolUi.Hint(ToolUi.Tr("tools/characters_editor/computed_hint")));
computedBox = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
block.AddChild(computedBox);
return block;
}
/// <summary>Refreshes the leader mark and the computed numbers for the shown character.</summary>
protected override void OnSelectionChanged(EntityRecord record)
{
if (leaderLabel == null || computedBox == null)
return;
shownId = record?.Id;
leaderLabel.Text = record == null ? string.Empty
: ToolUi.Tr(record.Id == LeaderId() ? "tools/characters_editor/leader_yes" : "tools/characters_editor/leader_no");
if (joinBox != null)
{
// The leader is the protagonist (stats.player): it never joins, and the check box says so.
joinBox.SetPressedNoSignal(record != null && CompanionScenario.Exists(ModId, record.Id));
joinBox.Disabled = record == null || record.Id == LeaderId();
}
foreach (var child in computedBox.GetChildren())
{
computedBox.RemoveChild(child);
child.QueueFree();
}
if (record == null || !HasStats(record.Data))
{
computedBox.AddChild(ToolUi.Hint(ToolUi.Tr("tools/characters_editor/computed_none")));
return;
}
foreach (var definition in StatsService.Definitions.Where(d => !string.IsNullOrEmpty(d.Binds)))
{
var row = string.Format(CultureInfo.InvariantCulture, ToolUi.Tr("tools/characters_editor/computed_row"),
ToolUi.Tr(definition.Name), StatValue(record.Id, definition.ID), definition.Binds);
computedBox.AddChild(ToolUi.Hint(row));
}
}
/// <summary>
/// Writes or removes the join scenario of the shown character (plan 14.G7) at once: the file is the whole change, the
/// map actor reads it the next time the character is chosen in the RPG map editor.
/// </summary>
private void OnJoinToggled(bool on)
{
if (string.IsNullOrEmpty(shownId) || shownId == LeaderId())
return;
var written = on ? CompanionScenario.Write(ModId, shownId) : CompanionScenario.Remove(ModId, shownId);
if (!written)
{
SetStatus(ToolUi.Tr("tools/common/readonly"), Cthangover.Mods.Interface.Tool.ToolStatusKind.Error);
joinBox.SetPressedNoSignal(!on);
return;
}
Cthangover.Core.Mods.ModManager.Instance.GetResolver<ScenarioResolver>()?.ClearCache();
SetStatus(string.Format(ToolUi.Tr(on ? "tools/characters_editor/join_on" : "tools/characters_editor/join_off"), shownId),
Cthangover.Mods.Interface.Tool.ToolStatusKind.Ok);
}
/// <summary>The character's sheet value of <paramref name="stat"/> (integer, invariant), or "-" when no sheet can be made.</summary>
private static string StatValue(string characterId, string stat)
{
try
{
var sheet = StatsService.Sheet(characterId);
return sheet != null && sheet.Has(stat) ? sheet.Rounded(stat).ToString(CultureInfo.InvariantCulture) : "-";
}
catch (System.Exception)
{
return "-";
}
}
/// <summary>A level 1 fighter with a plain attack; the portrait is the author's to pick.</summary>
protected override JsonObject CreateEntity(string id) => new()
{
+36
View File
@@ -0,0 +1,36 @@
namespace Cthangover.Tools.CharactersEditor
{
using Cthangover.Tools.Services;
/// <summary>
/// The join scenario of a character (plan 14.G7): <c>scenarios/&lt;mod&gt;/party/&lt;id&gt;.scenario</c>, which sets the join
/// flag and runs <c>action party.add</c>. The "Joins the party" block of this editor writes or removes it. The rpg
/// editor reads it when it makes the character's map actor (<c>RpgCharacterActors</c> in <c>rpg_editor</c>), which
/// takes the scenario key and the flag from the same convention: change both together.
/// </summary>
public static class CompanionScenario
{
/// <summary>The file of the join scenario, relative to the mod.</summary>
public static string Path(string mod, string characterId) => "scenarios/" + Key(mod, characterId) + ".scenario";
/// <summary>The scenario path in the <c>scenarios</c> group, without the extension.</summary>
public static string Key(string mod, string characterId) => mod + "/party/" + characterId;
/// <summary>The story flag the join sets to 1; the map actor of the character hides then.</summary>
public static string Flag(string characterId) => characterId + "_joined";
/// <summary>The text of the scenario: the flag, then the party change.</summary>
public static string Text(string characterId)
=> "flag " + Flag(characterId) + " 1\naction party.add id=" + characterId + "\nend\n";
/// <summary>True when the mod has the join scenario of the character.</summary>
public static bool Exists(string mod, string characterId)
=> !string.IsNullOrEmpty(mod) && !string.IsNullOrEmpty(characterId) && ModFileService.ReadText(mod, Path(mod, characterId)) != null;
/// <summary>Writes the join scenario; false when the mod is not writable.</summary>
public static bool Write(string mod, string characterId) => ModFileService.WriteText(mod, Path(mod, characterId), Text(characterId));
/// <summary>Removes the join scenario; false when the mod is not writable.</summary>
public static bool Remove(string mod, string characterId) => !Exists(mod, characterId) || ModFileService.DeleteFile(mod, Path(mod, characterId));
}
}