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