Files
trail-into-darkness/Assets/Code/GameState/Entities/CharacterAndPartyFactories.cs
2026-03-19 18:12:07 +01:00

470 lines
18 KiB
C#

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Nox.Game {
public enum CharacterRole {
Protagonist,
Companion
}
[Serializable]
public sealed class CharacterAttributes {
public int might;
public int reflex;
public int knowledge;
public int Total => might + reflex + knowledge;
}
[Serializable]
public sealed class CharacterStats {
public int maxHealth;
public int maxStamina;
public float dodgeStaminaLossMultiplier;
}
[Serializable]
public sealed class PerkDefinition {
public string id;
public string name;
public string mechanicalBonus;
public string thematicPenalty;
}
[Serializable]
public sealed class CharacterData {
public string id;
public string displayName;
public CharacterRole role;
public int level;
public int experience;
public CharacterAttributes attributes;
public CharacterStats stats;
public List<PerkDefinition> perks = new List<PerkDefinition>();
public CharacterData Clone() {
return new CharacterData {
id = id,
displayName = displayName,
role = role,
level = level,
experience = experience,
attributes = new CharacterAttributes {
might = attributes?.might ?? 0,
reflex = attributes?.reflex ?? 0,
knowledge = attributes?.knowledge ?? 0
},
stats = new CharacterStats {
maxHealth = stats?.maxHealth ?? 0,
maxStamina = stats?.maxStamina ?? 0,
dodgeStaminaLossMultiplier = stats?.dodgeStaminaLossMultiplier ?? 1f
},
perks = perks.Select(p => new PerkDefinition {
id = p.id,
name = p.name,
mechanicalBonus = p.mechanicalBonus,
thematicPenalty = p.thematicPenalty
}).ToList()
};
}
}
[Serializable]
public sealed class CharacterTemplate {
public string id;
public string displayName;
public CharacterAttributes attributes;
public int level = 1;
public int experience;
public List<string> startingPerkIds = new List<string>();
}
[Serializable]
public sealed class CustomCharacterCreationRequest {
public string id;
public string displayName;
public int mightPoints;
public int reflexPoints;
public int knowledgePoints;
public List<string> startingPerkIds = new List<string>();
}
[Serializable]
public sealed class PartyData {
public List<CharacterData> members = new List<CharacterData>();
public int maxPartySize;
[JsonIgnore]
public CharacterData Protagonist => members.FirstOrDefault(m => m.role == CharacterRole.Protagonist);
[JsonIgnore]
public IReadOnlyList<CharacterData> Companions => members.Where(m => m.role == CharacterRole.Companion).ToList();
}
public interface ICharacterAttributesFactory {
CharacterAttributes Create(int might, int reflex, int knowledge);
CharacterAttributes CreateFromPointAllocation(int mightPoints, int reflexPoints, int knowledgePoints);
}
public interface ICharacterStatsFactory {
CharacterStats Create(CharacterAttributes attributes);
}
public interface IPerkFactory {
IReadOnlyCollection<PerkDefinition> GetAll();
PerkDefinition GetById(string perkId);
IReadOnlyCollection<PerkDefinition> GetAvailableFor(CharacterData character);
bool TryAddPerk(CharacterData character, string perkId);
}
public interface ICharacterFactory {
CharacterData CreateCustomProtagonist(CustomCharacterCreationRequest request);
CharacterData CreateFromTemplate(CharacterTemplate template, CharacterRole role = CharacterRole.Companion);
}
public interface IPartyFactory {
PartyData Create(CharacterData protagonist, IEnumerable<CharacterData> companions = null);
}
public interface ICharacterSystems {
IPerkFactory PerkFactory { get; }
ICharacterFactory CharacterFactory { get; }
IPartyFactory PartyFactory { get; }
}
public sealed class CharacterFactoryOptions {
public int baseMight = 1;
public int baseReflex = 1;
public int baseKnowledge = 1;
public int customAttributePointBudget = 10;
public int startingLevel = 1;
}
public sealed class CharacterStatsFactoryOptions {
public int baseHealth = 10;
public int baseStamina = 5;
public int mightHealthBonus = 3;
public int mightStaminaBonus = 1;
public int knowledgeStaminaBonus = 2;
public float baseDodgeStaminaLossMultiplier = 1f;
public float reflexDodgeStaminaLossReduction = 0.03f;
public float minDodgeStaminaLossMultiplier = 0.4f;
}
public sealed class PartyFactoryOptions {
public int minPartySize = 1;
public int maxPartySize = 4;
public bool enforceUniqueCharacterIds = true;
}
public sealed class CharacterSystems : ICharacterSystems {
public CharacterSystems(IPerkFactory perkFactory, ICharacterFactory characterFactory, IPartyFactory partyFactory) {
PerkFactory = perkFactory;
CharacterFactory = characterFactory;
PartyFactory = partyFactory;
}
public IPerkFactory PerkFactory { get; }
public ICharacterFactory CharacterFactory { get; }
public IPartyFactory PartyFactory { get; }
}
public static class DefaultCharacterSystemsFactory {
public static ICharacterSystems Create(int maxPartySize = 8) {
IPerkFactory perkFactory = new PerkFactory(CreateDefaultPerks());
ICharacterAttributesFactory attributesFactory = new CharacterAttributesFactory(new CharacterFactoryOptions {
baseMight = 1,
baseReflex = 1,
baseKnowledge = 1,
customAttributePointBudget = 10,
startingLevel = 1
});
ICharacterStatsFactory statsFactory = new CharacterStatsFactory();
ICharacterFactory characterFactory = new CharacterFactory(attributesFactory, statsFactory, perkFactory);
IPartyFactory partyFactory = new PartyFactory(new PartyFactoryOptions {
minPartySize = 1,
maxPartySize = maxPartySize,
enforceUniqueCharacterIds = true
});
return new CharacterSystems(perkFactory, characterFactory, partyFactory);
}
private static IEnumerable<PerkDefinition> CreateDefaultPerks() {
return new[] {
new PerkDefinition { id = "iron-will", name = "Iron Will", mechanicalBonus = "+1 max health per level", thematicPenalty = "-1 social flexibility in dialogue checks" },
new PerkDefinition { id = "steadfast", name = "Steadfast", mechanicalBonus = "-10% stamina loss when bracing", thematicPenalty = "-10% movement speed in retreat events" },
new PerkDefinition { id = "nimble-step", name = "Nimble Step", mechanicalBonus = "-15% dodge stamina loss", thematicPenalty = "+10% stamina loss on heavy actions" },
new PerkDefinition { id = "lorekeeper", name = "Lorekeeper", mechanicalBonus = "+15% knowledge event success", thematicPenalty = "-10% intimidation success chance" },
new PerkDefinition { id = "bulwark", name = "Bulwark", mechanicalBonus = "+2 base defense checks", thematicPenalty = "-1 reflex in stealth checks" },
new PerkDefinition { id = "pathfinder", name = "Pathfinder", mechanicalBonus = "+15% scouting event success", thematicPenalty = "-1 max health during ambush events" }
};
}
}
public sealed class CharacterAttributesFactory : ICharacterAttributesFactory {
private readonly CharacterFactoryOptions options;
public CharacterAttributesFactory(CharacterFactoryOptions options = null) {
this.options = options ?? new CharacterFactoryOptions();
}
public CharacterAttributes Create(int might, int reflex, int knowledge) {
if(might < 0 || reflex < 0 || knowledge < 0) {
throw new ArgumentOutOfRangeException(nameof(might), "attributes cannot be negative.");
}
return new CharacterAttributes {
might = might,
reflex = reflex,
knowledge = knowledge
};
}
public CharacterAttributes CreateFromPointAllocation(int mightPoints, int reflexPoints, int knowledgePoints) {
if(mightPoints < 0 || reflexPoints < 0 || knowledgePoints < 0) {
throw new ArgumentOutOfRangeException(nameof(mightPoints), "Point allocation cannot be negative.");
}
int allocated = mightPoints + reflexPoints + knowledgePoints;
if(allocated > options.customAttributePointBudget) {
throw new ArgumentException($"Allocated {allocated} points but budget is {options.customAttributePointBudget}.");
}
return new CharacterAttributes {
might = options.baseMight + mightPoints,
reflex = options.baseReflex + reflexPoints,
knowledge = options.baseKnowledge + knowledgePoints
};
}
}
public sealed class CharacterStatsFactory : ICharacterStatsFactory {
private readonly CharacterStatsFactoryOptions options;
public CharacterStatsFactory(CharacterStatsFactoryOptions options = null) {
this.options = options ?? new CharacterStatsFactoryOptions();
}
public CharacterStats Create(CharacterAttributes attributes) {
if(attributes == null) {
throw new ArgumentNullException(nameof(attributes));
}
int maxHealth = options.baseHealth + attributes.might * options.mightHealthBonus;
int maxStamina = options.baseStamina +
attributes.might * options.mightStaminaBonus +
attributes.knowledge * options.knowledgeStaminaBonus;
float dodgeMultiplier = options.baseDodgeStaminaLossMultiplier -
attributes.reflex * options.reflexDodgeStaminaLossReduction;
dodgeMultiplier = Math.Max(options.minDodgeStaminaLossMultiplier, dodgeMultiplier);
return new CharacterStats {
maxHealth = maxHealth,
maxStamina = maxStamina,
dodgeStaminaLossMultiplier = dodgeMultiplier
};
}
}
public sealed class PerkFactory : IPerkFactory {
private readonly Dictionary<string, PerkDefinition> perkPool;
public PerkFactory(IEnumerable<PerkDefinition> perkPool) {
if(perkPool == null) {
throw new ArgumentNullException(nameof(perkPool));
}
this.perkPool = perkPool
.Where(p => p != null && !string.IsNullOrWhiteSpace(p.id))
.GroupBy(p => p.id)
.ToDictionary(g => g.Key, g => g.First());
}
public IReadOnlyCollection<PerkDefinition> GetAll() {
return perkPool.Values.ToList();
}
public PerkDefinition GetById(string perkId) {
if(string.IsNullOrWhiteSpace(perkId)) {
return null;
}
perkPool.TryGetValue(perkId, out PerkDefinition perk);
return perk;
}
public IReadOnlyCollection<PerkDefinition> GetAvailableFor(CharacterData character) {
if(character == null) {
return perkPool.Values.ToList();
}
HashSet<string> ownedPerkIds = character.perks
.Where(p => p != null && !string.IsNullOrWhiteSpace(p.id))
.Select(p => p.id)
.ToHashSet();
return perkPool.Values.Where(p => !ownedPerkIds.Contains(p.id)).ToList();
}
public bool TryAddPerk(CharacterData character, string perkId) {
if(character == null || string.IsNullOrWhiteSpace(perkId)) {
return false;
}
if(character.perks.Any(p => p != null && p.id == perkId)) {
return false;
}
if(!perkPool.TryGetValue(perkId, out PerkDefinition perk)) {
return false;
}
character.perks.Add(new PerkDefinition {
id = perk.id,
name = perk.name,
mechanicalBonus = perk.mechanicalBonus,
thematicPenalty = perk.thematicPenalty
});
return true;
}
}
public sealed class CharacterFactory : ICharacterFactory {
private readonly CharacterFactoryOptions options;
private readonly ICharacterAttributesFactory attributesFactory;
private readonly ICharacterStatsFactory statsFactory;
private readonly IPerkFactory perkFactory;
public CharacterFactory(
ICharacterAttributesFactory attributesFactory,
ICharacterStatsFactory statsFactory,
IPerkFactory perkFactory,
CharacterFactoryOptions options = null) {
this.attributesFactory = attributesFactory ?? throw new ArgumentNullException(nameof(attributesFactory));
this.statsFactory = statsFactory ?? throw new ArgumentNullException(nameof(statsFactory));
this.perkFactory = perkFactory ?? throw new ArgumentNullException(nameof(perkFactory));
this.options = options ?? new CharacterFactoryOptions();
}
public CharacterData CreateCustomProtagonist(CustomCharacterCreationRequest request) {
if(request == null) {
throw new ArgumentNullException(nameof(request));
}
CharacterAttributes attributes = attributesFactory.CreateFromPointAllocation(
request.mightPoints,
request.reflexPoints,
request.knowledgePoints);
CharacterData character = new CharacterData {
id = string.IsNullOrWhiteSpace(request.id) ? Guid.NewGuid().ToString("N") : request.id,
displayName = request.displayName,
role = CharacterRole.Protagonist,
level = options.startingLevel,
experience = 0,
attributes = attributes,
stats = statsFactory.Create(attributes)
};
AddStartingPerks(character, request.startingPerkIds);
return character;
}
public CharacterData CreateFromTemplate(CharacterTemplate template, CharacterRole role = CharacterRole.Companion) {
if(template == null) {
throw new ArgumentNullException(nameof(template));
}
CharacterAttributes sourceAttributes = template.attributes ?? attributesFactory.Create(0, 0, 0);
CharacterAttributes attributes = attributesFactory.Create(
sourceAttributes.might,
sourceAttributes.reflex,
sourceAttributes.knowledge);
CharacterData character = new CharacterData {
id = string.IsNullOrWhiteSpace(template.id) ? Guid.NewGuid().ToString("N") : template.id,
displayName = template.displayName,
role = role,
level = template.level <= 0 ? options.startingLevel : template.level,
experience = template.experience,
attributes = attributes,
stats = statsFactory.Create(attributes)
};
AddStartingPerks(character, template.startingPerkIds);
return character;
}
private void AddStartingPerks(CharacterData character, IEnumerable<string> perkIds) {
if(perkIds == null) {
return;
}
foreach(string perkId in perkIds.Distinct()) {
perkFactory.TryAddPerk(character, perkId);
}
}
}
public sealed class PartyFactory : IPartyFactory {
private readonly PartyFactoryOptions options;
public PartyFactory(PartyFactoryOptions options = null) {
this.options = options ?? new PartyFactoryOptions();
}
public PartyData Create(CharacterData protagonist, IEnumerable<CharacterData> companions = null) {
if(protagonist == null) {
throw new ArgumentNullException(nameof(protagonist));
}
PartyData party = new PartyData {
maxPartySize = options.maxPartySize <= 0 ? int.MaxValue : options.maxPartySize
};
CharacterData protagonistClone = protagonist.Clone();
protagonistClone.role = CharacterRole.Protagonist;
party.members.Add(protagonistClone);
if(companions != null) {
foreach(CharacterData companion in companions.Where(c => c != null)) {
CharacterData companionClone = companion.Clone();
companionClone.role = CharacterRole.Companion;
party.members.Add(companionClone);
}
}
ValidateParty(party);
return party;
}
private void ValidateParty(PartyData party) {
if(party.members.Count < options.minPartySize) {
throw new ArgumentException($"Party size {party.members.Count} is below minimum {options.minPartySize}.");
}
if(party.members.Count > party.maxPartySize) {
throw new ArgumentException($"Party size {party.members.Count} exceeds max {party.maxPartySize}.");
}
int protagonistCount = party.members.Count(m => m.role == CharacterRole.Protagonist);
if(protagonistCount != 1) {
throw new ArgumentException($"Party must contain exactly one protagonist, found {protagonistCount}.");
}
if(options.enforceUniqueCharacterIds) {
int uniqueIds = party.members
.Where(m => !string.IsNullOrWhiteSpace(m.id))
.Select(m => m.id)
.Distinct()
.Count();
if(uniqueIds != party.members.Count) {
throw new ArgumentException("Party contains duplicate or missing character ids.");
}
}
}
}
}