Character factories follow mod reloads

This commit is contained in:
cthangover committed 2026-10-21 12:31:48 +02:00
1 parent 734169db3d
commit 3afa3a6a80
11 files changed
+365 -363

No files matched your search

+37 -37
View File
@@ -1,37 +1,37 @@
# Godot 4.x # Godot 4.x
.godot/ .godot/
.idea/ .idea/
sdk/ sdk/
bin/ bin/
obj/ obj/
#ai #ai
.omo/ .omo/
.opencode/ .opencode/
gen/ gen/
obj/ obj/
bin/ bin/
*.tres~ *.tres~
*.tscn~ *.tscn~
# GDScript LSP # GDScript LSP
.godot/ .godot/
# Export # Export
*.exe *.exe
*.dmg *.dmg
*.apk *.apk
*.aab *.aab
*.pck *.pck
*.zip *.zip
*.log *.log
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Editor # Editor
*.import *.import
*.godot.uid *.godot.uid
+16 -16
View File
@@ -1,17 +1,17 @@
{ {
"Kind": "action", "Kind": "action",
"Items": [ "Items": [
{ {
"Id": "physics/attack", "Id": "physics/attack",
"Name": "characters/action/physics_attack/name", "Name": "characters/action/physics_attack/name",
"Description": "characters/action/physics_attack/description", "Description": "characters/action/physics_attack/description",
"Image": "characters/actions/physics_attack", "Image": "characters/actions/physics_attack",
"Type": "ToEnemy", "Type": "ToEnemy",
"Values": [ "Values": [
"RequiredPoint=1", "RequiredPoint=1",
"Attack=1.0", "Attack=1.0",
"AttackType=Physics" "AttackType=Physics"
] ]
} }
] ]
} }
+15 -15
View File
@@ -1,16 +1,16 @@
{ {
"Kind": "action", "Kind": "action",
"Items": [ "Items": [
{ {
"Id": "physics/defence", "Id": "physics/defence",
"Name": "characters/action/defence/name", "Name": "characters/action/defence/name",
"Description": "characters/action/defence/description", "Description": "characters/action/defence/description",
"Image": "characters/actions/physics_defence", "Image": "characters/actions/physics_defence",
"Type": "ToAlias", "Type": "ToAlias",
"Values": [ "Values": [
"RequiredPoint=1", "RequiredPoint=1",
"Defence=4" "Defence=4"
] ]
} }
] ]
} }
+16 -16
View File
@@ -1,17 +1,17 @@
{ {
"Kind": "action", "Kind": "action",
"Items": [ "Items": [
{ {
"Id": "physics/stun", "Id": "physics/stun",
"Name": "characters/action/stun/name", "Name": "characters/action/stun/name",
"Description": "characters/action/stun/description", "Description": "characters/action/stun/description",
"Image": "characters/actions/physics_stun", "Image": "characters/actions/physics_stun",
"Type": "ToEnemy", "Type": "ToEnemy",
"Values": [ "Values": [
"RequiredPoint=2", "RequiredPoint=2",
"AttackType=Physics", "AttackType=Physics",
"Turn=3" "Turn=3"
] ]
} }
] ]
} }
@@ -1,63 +1,63 @@
namespace Cthangover.Core.Characters namespace Cthangover.Core.Characters
{ {
/// <summary> /// <summary>
/// Cooldown helpers for <see cref="ActionCharacterType.Active"/> cards. /// Cooldown helpers for <see cref="ActionCharacterType.Active"/> cards.
/// Active cards are one-shot abilities usable outside battle (from the /// Active cards are one-shot abilities usable outside battle (from the
/// character panel). The last-use timestamp is stored in /// character panel). The last-use timestamp is stored in
/// <see cref="ActionCharacter.Properties"/> under /// <see cref="ActionCharacter.Properties"/> under
/// 'LastUsed' - the cooldown duration /// 'LastUsed' - the cooldown duration
/// (seconds) under 'ActiveCooldown' /// (seconds) under 'ActiveCooldown'
/// Absence of <c>LastUsed</c> means the card has never been activated. /// Absence of <c>LastUsed</c> means the card has never been activated.
/// </summary> /// </summary>
public static class ActionCharacterActiveExtension public static class ActionCharacterActiveExtension
{ {
/// <summary> /// <summary>
/// Returns <c>true</c> when the card is <see cref="ActionCharacterType.Active"/> /// Returns <c>true</c> when the card is <see cref="ActionCharacterType.Active"/>
/// and its cooldown has expired (or it was never used). /// and its cooldown has expired (or it was never used).
/// </summary> /// </summary>
public static bool IsReadyToUse(this ActionCharacter action, long currentTime) public static bool IsReadyToUse(this ActionCharacter action, long currentTime)
{ {
if (action.Type != ActionCharacterType.Active) if (action.Type != ActionCharacterType.Active)
return false; return false;
var lastUsed = action.Properties.GetLong("LastUsed", 0); var lastUsed = action.Properties.GetLong("LastUsed", 0);
var cooldown = action.GetInt("ActiveCooldown", 0); var cooldown = action.GetInt("ActiveCooldown", 0);
if (lastUsed == 0) if (lastUsed == 0)
return true; return true;
if (cooldown <= 0) if (cooldown <= 0)
return false; return false;
return currentTime - lastUsed >= cooldown; return currentTime - lastUsed >= cooldown;
} }
/// <summary> /// <summary>
/// Seconds remaining until the cooldown expires. Returns <c>0</c> when /// Seconds remaining until the cooldown expires. Returns <c>0</c> when
/// ready, never used, or not an Active card. /// ready, never used, or not an Active card.
/// </summary> /// </summary>
public static long GetRemainingCooldown(this ActionCharacter action, long currentTime) public static long GetRemainingCooldown(this ActionCharacter action, long currentTime)
{ {
if (action.Type != ActionCharacterType.Active) if (action.Type != ActionCharacterType.Active)
return 0; return 0;
var lastUsed = action.Properties.GetLong("LastUsed", 0); var lastUsed = action.Properties.GetLong("LastUsed", 0);
var cooldown = action.GetInt("ActiveCooldown", 0); var cooldown = action.GetInt("ActiveCooldown", 0);
if (lastUsed == 0 || cooldown <= 0) if (lastUsed == 0 || cooldown <= 0)
return 0; return 0;
var elapsed = currentTime - lastUsed; var elapsed = currentTime - lastUsed;
return elapsed >= cooldown ? 0 : cooldown - elapsed; return elapsed >= cooldown ? 0 : cooldown - elapsed;
} }
/// <summary> /// <summary>
/// Records the activation timestamp. Has no effect on non-Active cards. /// Records the activation timestamp. Has no effect on non-Active cards.
/// </summary> /// </summary>
public static void MarkUsed(this ActionCharacter action, long timestamp) public static void MarkUsed(this ActionCharacter action, long timestamp)
{ {
if (action.Type != ActionCharacterType.Active) if (action.Type != ActionCharacterType.Active)
return; return;
action.Properties.SetLong("LastUsed", timestamp); action.Properties.SetLong("LastUsed", timestamp);
} }
} }
} }
+56 -56
View File
@@ -1,56 +1,56 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Cthangover.Core.Factories; using Cthangover.Core.Factories;
namespace Cthangover.Core.Characters namespace Cthangover.Core.Characters
{ {
/// <summary> /// <summary>
/// JSON-serializable descriptor for an ActionCharacter. The Values list is a /// JSON-serializable descriptor for an ActionCharacter. The Values list is a
/// flat string array carrying the PropertyData pairs — serialized as a list /// flat string array carrying the PropertyData pairs — serialized as a list
/// rather than a dictionary to maintain JSON readability and avoid key /// rather than a dictionary to maintain JSON readability and avoid key
/// escaping issues. Type uses a string enum converter so the JSON reads /// escaping issues. Type uses a string enum converter so the JSON reads
/// "ToSelf"/"ToAlias"/"ToEnemy" instead of integers. The Image field is a /// "ToSelf"/"ToAlias"/"ToEnemy" instead of integers. The Image field is a
/// resource path string, resolved to Texture2D by the factory at load time. /// resource path string, resolved to Texture2D by the factory at load time.
/// </summary> /// </summary>
[Serializable] [Serializable]
[EntityKind("action")] [EntityKind("action")]
public class ActionCharacterInfo : IIdentifiable public class ActionCharacterInfo : IIdentifiable
{ {
/// <summary> /// <summary>
/// Unique action identifier, serialized as "Id" in JSON. /// Unique action identifier, serialized as "Id" in JSON.
/// </summary> /// </summary>
[JsonPropertyName("Id")] [JsonPropertyName("Id")]
public string ID { get; set; } public string ID { get; set; }
/// <summary> /// <summary>
/// Display name for the battle action menu. /// Display name for the battle action menu.
/// </summary> /// </summary>
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// Description text for the battle action tooltip. /// Description text for the battle action tooltip.
/// </summary> /// </summary>
public string Description { get; set; } public string Description { get; set; }
/// <summary> /// <summary>
/// Targeting category, serialized as string (e.g. "ToSelf", /// Targeting category, serialized as string (e.g. "ToSelf",
/// "ToAlias", "ToEnemy") via <see cref="JsonStringEnumConverter"/> /// "ToAlias", "ToEnemy") via <see cref="JsonStringEnumConverter"/>
/// for human-readable JSON. /// for human-readable JSON.
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))] [JsonConverter(typeof(JsonStringEnumConverter))]
public ActionCharacterType Type { get; set; } public ActionCharacterType Type { get; set; }
/// <summary> /// <summary>
/// Resource path string for the action icon. Resolved to /// Resource path string for the action icon. Resolved to
/// <c>Texture2D</c> by <see cref="CharacterFactory"/> at load /// <c>Texture2D</c> by <see cref="CharacterFactory"/> at load
/// time. /// time.
/// </summary> /// </summary>
public string Image { get; set; } public string Image { get; set; }
/// <summary> /// <summary>
/// Flat list of alternating key-value pairs that populate the /// Flat list of alternating key-value pairs that populate the
/// <see cref="ActionCharacter.Properties"/> bag at load time. /// <see cref="ActionCharacter.Properties"/> bag at load time.
/// Index 0 = first key, index 1 = first value, index 2 = second /// Index 0 = first key, index 1 = first value, index 2 = second
/// key, etc. Stored as a list rather than a dictionary to /// key, etc. Stored as a list rather than a dictionary to
/// simplify JSON authoring and avoid key-escaping issues. /// simplify JSON authoring and avoid key-escaping issues.
/// </summary> /// </summary>
public List<string> Values { get; set; } public List<string> Values { get; set; }
} }
} }
+93 -93
View File
@@ -1,93 +1,93 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Cthangover.Core.Factories; using Cthangover.Core.Factories;
namespace Cthangover.Core.Characters namespace Cthangover.Core.Characters
{ {
/// <summary> /// <summary>
/// JSON-serializable character descriptor loaded from data files. /// JSON-serializable character descriptor loaded from data files.
/// Only the four core battle stats (Health, Defence, Attack, Point) /// Only the four core battle stats (Health, Defence, Attack, Point)
/// are hardcoded — they map directly to <see cref="Attribute"/> fields /// are hardcoded — they map directly to <see cref="Attribute"/> fields
/// in <see cref="CharacterAttributes"/>. All other properties /// in <see cref="CharacterAttributes"/>. All other properties
/// (any mod-defined extras) are captured by <see cref="ExtraProperties"/> /// (any mod-defined extras) are captured by <see cref="ExtraProperties"/>
/// and flowed into <see cref="CharacterAttributes.Properties"/> at /// and flowed into <see cref="CharacterAttributes.Properties"/> at
/// factory time, so mods can add arbitrary stat keys without /// factory time, so mods can add arbitrary stat keys without
/// changing the DTO schema. /// changing the DTO schema.
/// </summary> /// </summary>
[Serializable] [Serializable]
[EntityKind("character")] [EntityKind("character")]
public class CharacterInfo : IIdentifiable public class CharacterInfo : IIdentifiable
{ {
/// <summary> /// <summary>
/// Unique character ID, serialized as "Id" in JSON. Serves as the /// Unique character ID, serialized as "Id" in JSON. Serves as the
/// factory lookup key and save-data identifier. /// factory lookup key and save-data identifier.
/// </summary> /// </summary>
[JsonPropertyName("Id")] [JsonPropertyName("Id")]
public string ID { get; set; } public string ID { get; set; }
/// <summary> /// <summary>
/// AI behaviour class name for this character in battle. Resolved /// AI behaviour class name for this character in battle. Resolved
/// externally — null means no AI override. /// externally — null means no AI override.
/// </summary> /// </summary>
public string Behaviour { get; set; } public string Behaviour { get; set; }
/// <summary> /// <summary>
/// Starting level for this character when recruited. /// Starting level for this character when recruited.
/// </summary> /// </summary>
public int Level { get; set; } public int Level { get; set; }
/// <summary> /// <summary>
/// Resource path string for the full-body portrait. Resolved to a /// Resource path string for the full-body portrait. Resolved to a
/// <c>Texture2D</c> by <see cref="CharacterFactory"/> at load time. /// <c>Texture2D</c> by <see cref="CharacterFactory"/> at load time.
/// </summary> /// </summary>
public string Image { get; set; } public string Image { get; set; }
/// <summary> /// <summary>
/// Display name, localized via TranslationServer when shown to the /// Display name, localized via TranslationServer when shown to the
/// player. /// player.
/// </summary> /// </summary>
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// Starting health points (used as <see cref="Attribute"/>.BaseValue). /// Starting health points (used as <see cref="Attribute"/>.BaseValue).
/// </summary> /// </summary>
public int Health { get; set; } public int Health { get; set; }
/// <summary> /// <summary>
/// Starting defence value. /// Starting defence value.
/// </summary> /// </summary>
public int Defence { get; set; } public int Defence { get; set; }
/// <summary> /// <summary>
/// Starting action points (resource pool for abilities). /// Starting action points (resource pool for abilities).
/// </summary> /// </summary>
public int Point { get; set; } public int Point { get; set; }
/// <summary> /// <summary>
/// Starting attack power. /// Starting attack power.
/// </summary> /// </summary>
public int Attack { get; set; } public int Attack { get; set; }
/// <summary> /// <summary>
/// Starting experience points. /// Starting experience points.
/// </summary> /// </summary>
public int Exp { get; set; } public int Exp { get; set; }
/// <summary> /// <summary>
/// Recruitment chance percentage (0–100) after defeating this /// Recruitment chance percentage (0–100) after defeating this
/// character in battle. /// character in battle.
/// </summary> /// </summary>
public int RecruitmentChance { get; set; } public int RecruitmentChance { get; set; }
/// <summary> /// <summary>
/// Action ID strings, each referencing an /// Action ID strings, each referencing an
/// <see cref="ActionCharacterInfo"/> entry in the action data file. /// <see cref="ActionCharacterInfo"/> entry in the action data file.
/// Stored as a JSON array for clean mod-patching with /// Stored as a JSON array for clean mod-patching with
/// <c>$add</c>/<c>$remove</c>/<c>$set</c> suffix operations. /// <c>$add</c>/<c>$remove</c>/<c>$set</c> suffix operations.
/// </summary> /// </summary>
public List<string> Actions { get; set; } public List<string> Actions { get; set; }
/// <summary> /// <summary>
/// Loot drop table for this character when defeated as an enemy. /// Loot drop table for this character when defeated as an enemy.
/// </summary> /// </summary>
public List<LootEntry> Loot { get; set; } public List<LootEntry> Loot { get; set; }
/// <summary> /// <summary>
/// Captures any JSON keys not matched by the hardcoded properties /// Captures any JSON keys not matched by the hardcoded properties
/// above — including former DTO fields like any mod-defined extras. /// above — including former DTO fields like any mod-defined extras.
/// These are imported into <see cref="CharacterAttributes.Properties"/> /// These are imported into <see cref="CharacterAttributes.Properties"/>
/// by <see cref="CharacterFactory.ImportExtraProperty"/>. /// by <see cref="CharacterFactory.ImportExtraProperty"/>.
/// </summary> /// </summary>
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, object> ExtraProperties { get; set; } public Dictionary<string, object> ExtraProperties { get; set; }
} }
} }
+25 -25
View File
@@ -1,25 +1,25 @@
using System.Linq; using System.Linq;
namespace Cthangover.Core.Characters namespace Cthangover.Core.Characters
{ {
/// <summary> /// <summary>
/// Extension for querying passive effects on a <see cref="Character"/>. /// Extension for querying passive effects on a <see cref="Character"/>.
/// Passive actions are <see cref="ActionCharacter"/> entries with /// Passive actions are <see cref="ActionCharacter"/> entries with
/// <see cref="ActionCharacterType.Passive"/> whose <see cref="ActionCharacter.Properties"/> /// <see cref="ActionCharacterType.Passive"/> whose <see cref="ActionCharacter.Properties"/>
/// declare effect flags as boolean keys (e.g. "IgnoreArmor"). The key names /// declare effect flags as boolean keys (e.g. "IgnoreArmor"). The key names
/// are chosen by the mod author — the core imposes no registry or constants. /// are chosen by the mod author — the core imposes no registry or constants.
/// </summary> /// </summary>
public static class CharacterPassiveExtension public static class CharacterPassiveExtension
{ {
/// <summary> /// <summary>
/// Returns <c>true</c> if the character has at least one Passive action /// Returns <c>true</c> if the character has at least one Passive action
/// whose <see cref="ActionCharacter.Properties"/> contains <paramref name="key"/> /// whose <see cref="ActionCharacter.Properties"/> contains <paramref name="key"/>
/// with a <c>true</c> boolean value. /// with a <c>true</c> boolean value.
/// </summary> /// </summary>
public static bool HasPassiveEffect(this Character character, string key) public static bool HasPassiveEffect(this Character character, string key)
{ {
return character?.Actions?.Any(a => return character?.Actions?.Any(a =>
a.Type == ActionCharacterType.Passive && a.GetBool(key)) ?? false; a.Type == ActionCharacterType.Passive && a.GetBool(key)) ?? false;
} }
} }
} }
+5 -4
View File
@@ -56,12 +56,13 @@ namespace Cthangover.Core.Factories.Impls
/// </summary> /// </summary>
private Dictionary<string, ActionCharacterInfo> EnsureInfos() private Dictionary<string, ActionCharacterInfo> EnsureInfos()
{ {
if (_allInfos != null) // The resolver's dictionary identity is the data snapshot (see CharacterFactory.EnsureInfos).
return _allInfos;
var infos = Entities.Collect<ActionCharacterInfo>(); var infos = Entities.Collect<ActionCharacterInfo>();
if (ModManager.Instance.IsInitialized) if (!ReferenceEquals(infos, _allInfos))
{
_allInfos = infos; _allInfos = infos;
_cache.Clear();
}
return infos; return infos;
} }
+30 -30
View File
@@ -1,30 +1,30 @@
using Cthangover.Core.Factories; using Cthangover.Core.Factories;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
namespace Cthangover.Core.Factories.Impls namespace Cthangover.Core.Factories.Impls
{ {
/// <summary> /// <summary>
/// Thin <c>Texture2DFactory</c> for character portrait avatars. The /// Thin <c>Texture2DFactory</c> for character portrait avatars. The
/// separate factory (as opposed to reusing <c>UITextureFactory</c>) /// separate factory (as opposed to reusing <c>UITextureFactory</c>)
/// isolates avatar images into their own mod group with a dedicated /// isolates avatar images into their own mod group with a dedicated
/// cache budget, so frequently-swapped dialog portraits don't evict /// cache budget, so frequently-swapped dialog portraits don't evict
/// other UI textures under memory pressure. /// other UI textures under memory pressure.
/// <para/> /// <para/>
/// The factory lives in the characters engine mod; its singleton is also /// The factory lives in the characters engine mod; its singleton is also
/// published to the kernel through <see cref="AvatarProviderRegistry"/> by /// published to the kernel through <see cref="AvatarProviderRegistry"/> by
/// <c>CharactersModInitializer</c>, so core-side consumers resolve /// <c>CharactersModInitializer</c>, so core-side consumers resolve
/// avatars without referencing this assembly. /// avatars without referencing this assembly.
/// </summary> /// </summary>
public class AvatarFactory : Texture2DFactory, IAvatarProvider public class AvatarFactory : Texture2DFactory, IAvatarProvider
{ {
internal AvatarFactory() : base("avatars", 64) { } internal AvatarFactory() : base("avatars", 64) { }
/// <summary>Process-wide singleton for this mod assembly; the public API for avatar lookups.</summary> /// <summary>Process-wide singleton for this mod assembly; the public API for avatar lookups.</summary>
public static AvatarFactory Instance { get; } = new AvatarFactory(); public static AvatarFactory Instance { get; } = new AvatarFactory();
/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>avatars</b>.</summary> /// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>avatars</b>.</summary>
public override string GroupName => "avatars"; public override string GroupName => "avatars";
} }
} }
+9 -8
View File
@@ -64,16 +64,17 @@ namespace Cthangover.Core.Factories.Impls
/// </summary> /// </summary>
private Dictionary<string, CharacterInfo> EnsureInfos() private Dictionary<string, CharacterInfo> EnsureInfos()
{ {
if (_allInfos != null) // Entities.Collect is cached by the entity resolver until the next
return _allInfos; // mod reload, so its dictionary identity is the data snapshot: a new
// one (reload, data edit, game switch with this mod kept) drops the
GameLogger.Log("FACTORY", $"CharacterFactory.EnsureInfos: START, ModRegistry.IsInitialized={ModRegistry.Instance.IsInitialized}, mods count={ModRegistry.Instance.Mods?.Count ?? 0}", LogLevel.Debug); // built templates instead of serving the previous mod set's data.
var infos = Entities.Collect<CharacterInfo>(); var infos = Entities.Collect<CharacterInfo>();
if (ModManager.Instance.IsInitialized) if (ReferenceEquals(infos, _allInfos))
_allInfos = infos; return infos;
GameLogger.Log("FACTORY", $"CharacterFactory.EnsureInfos: END, loaded {infos?.Count ?? 0} infos", LogLevel.Debug); _allInfos = infos;
_cache.Clear();
GameLogger.Log("FACTORY", $"CharacterFactory.EnsureInfos: {infos?.Count ?? 0} infos (new data snapshot)", LogLevel.Debug);
return infos; return infos;
} }