using Jovian.InGameLogging; using Jovian.InGameLogging.UI; using Jovian.SaveSystem; using Nox.Core; using Nox.Game; using Nox.Game.UI; using System; using System.Collections.Generic; using TMPro; using UnityEngine; using ZLinq; using Attribute = Nox.Game.Attribute; using PlayMode = Nox.Core.PlayMode; namespace Nox.UI { public class CharacterCreationView : IGameLifecycle, IMenuView { public ISaveSystem SaveSystem { get; } private readonly CharacterCreationReference characterCreationReference; private readonly MenuGameStateData menuGameStateData; private readonly GameDataState gameDataState; private readonly PartySettings partySettings; private readonly ICharacterSystems characterSystems; private readonly PortraitsHolder portraitsHolder; private readonly StarterCharacterSettings starterCharacterSettings; // Logger private GameLogView gameLogView; private InGameLogger inGameLogger; // Working state private CharacterRace selectedRace; private CharacterClass selectedClass; private int currentPortraitIndex; private int remainingPoints; private readonly int[] allocatedPoints = new int[4]; // Might, Reflex, Knowledge, Perception (AttributeType 1-4) private int previousHealth; private int previousStamina; // Modifier source tracking private PerksData racialPerks = new(); private ModifiersData racialModifiers = new(); private PerksData classPerks = new(); private ModifiersData classModifiers = new(); private readonly List playerPerks = new(); private List availablePerks = new(); // Computed state private EntityAttributes workingAttributes; private EntityStats workingStats; // Output private List characterCreationRequests; private Action canStartCheck; // Back confirmation (null until popup is implemented) private Action confirmBackAction; public CharacterCreationView(CharacterCreationReference characterCreationReference, MenuGameStateData menuGameStateData, ISaveSystem saveSystem, GameDataState gameDataState, PartySettings partySettings, ICharacterSystems characterSystems, PortraitsHolder portraitsHolder, StarterCharacterSettings starterCharacterSettings) { SaveSystem = saveSystem; this.characterCreationReference = characterCreationReference; this.menuGameStateData = menuGameStateData; this.gameDataState = gameDataState; this.partySettings = partySettings; this.characterSystems = characterSystems; this.portraitsHolder = portraitsHolder; this.starterCharacterSettings = starterCharacterSettings; } public void Initialize() { // Logger var store = new GameLogStore(500); gameLogView = characterCreationReference.gameLogView; gameLogView.Initialize(store); inGameLogger = new InGameLogger(store, LogChannel.CharacterCreation); inGameLogger.Enable(); // Start Game button canStartCheck = () => { var canStart = characterCreationRequests is { Count: > 0 }; characterCreationReference.startGameButton.interactable = canStart; }; characterCreationReference.startGameButton.interactable = false; characterCreationReference.startGameButton.onClick.AddListener(() => { Hide(); menuGameStateData.startGameRequests?.Invoke(PlayMode.Adventure); }); // Back buttons with popup check characterCreationReference.backButton.onClick.AddListener(OnBackClicked); characterCreationReference.backButtonCenter.onClick.AddListener(OnBackClicked); // Accept button characterCreationReference.acceptButton.onClick.AddListener(OnAcceptClicked); // Race dropdown PopulateEnumDropdown(characterCreationReference.raceDropdown); characterCreationReference.raceDropdown.onValueChanged.AddListener(OnRaceChanged); // Class dropdown PopulateEnumDropdown(characterCreationReference.classDropdown); characterCreationReference.classDropdown.onValueChanged.AddListener(OnClassChanged); // Perks dropdown PopulatePerksDropdown(); characterCreationReference.perksDropdown.onValueChanged.AddListener(OnPerkSelected); // Attribute +/- buttons var attrTypes = new[] { AttributeType.Might, AttributeType.Reflex, AttributeType.Knowledge, AttributeType.Perception }; var attrRefs = characterCreationReference.attributeReference; for(int i = 0; i < attrRefs.Length && i < attrTypes.Length; i++) { var type = attrTypes[i]; attrRefs[i].attributeName.text = type.ToString(); attrRefs[i].addPointsButton.onClick.AddListener(() => OnAttributeAdd(type)); attrRefs[i].removePointsButton.onClick.AddListener(() => OnAttributeRemove(type)); } // Portrait navigation currentPortraitIndex = 0; if(portraitsHolder != null && portraitsHolder.portraits.Length > 0) { characterCreationReference.portraitImage.sprite = portraitsHolder.portraits[0]; } characterCreationReference.portraitSelectionLeftButton.onClick.AddListener(OnPortraitLeft); characterCreationReference.portraitSelectionRightButton.onClick.AddListener(OnPortraitRight); // Initial state selectedRace = CharacterRace.Human; selectedClass = CharacterClass.Warrior; characterCreationReference.raceDropdown.SetValueWithoutNotify(0); characterCreationReference.classDropdown.SetValueWithoutNotify(0); ResetWorkingState(); } // --- Dropdown helpers --- private void PopulateEnumDropdown(TMP_Dropdown dropdown) where T : Enum { dropdown.ClearOptions(); var options = new List(); foreach(T value in Enum.GetValues(typeof(T))) { if(Convert.ToInt32(value) == 0) { continue; // skip None } options.Add(value.ToString()); } dropdown.AddOptions(options); } private void PopulatePerksDropdown() { var dropdown = characterCreationReference.perksDropdown; dropdown.ClearOptions(); availablePerks = new List(characterSystems.PerkFactory.GetAll()); var options = new List { "Select a perk..." }; foreach(var perk in availablePerks) { options.Add(perk.Name); } dropdown.AddOptions(options); dropdown.SetValueWithoutNotify(0); } // --- State management --- private void ResetWorkingState() { Array.Clear(allocatedPoints, 0, allocatedPoints.Length); racialPerks = new PerksData(); racialModifiers = new ModifiersData(); classPerks = new PerksData(); classModifiers = new ModifiersData(); playerPerks.Clear(); ApplyRacialBonuses(); ApplyClassBonuses(); UpdateRemainingPoints(); RecalculateAll(); // Initialize previous values so first change doesn't log a delta from 0 previousHealth = workingStats.GetValue(StatType.Health); previousStamina = workingStats.GetValue(StatType.Mana); } private void ApplyRacialBonuses() { racialPerks = new PerksData(); racialModifiers = new ModifiersData(); var bonuses = starterCharacterSettings.racialBonuses; if(bonuses == null) { return; } foreach(var rb in bonuses) { if(rb.race == selectedRace) { racialPerks = rb.startingPerks ?? new PerksData(); racialModifiers = rb.permanentModifiers ?? new ModifiersData(); break; } } } private void ApplyClassBonuses() { classPerks = new PerksData(); classModifiers = new ModifiersData(); var bonuses = starterCharacterSettings.classBonuses; if(bonuses == null) { return; } foreach(var cb in bonuses) { if(cb.@class == selectedClass) { classPerks = cb.startingPerks ?? new PerksData(); classModifiers = cb.permanentModifiers ?? new ModifiersData(); break; } } } private void UpdateRemainingPoints() { var totalPoints = 0; if(starterCharacterSettings.distributionPointsPerClass != null) { foreach(var dpc in starterCharacterSettings.distributionPointsPerClass) { if(dpc.@class == selectedClass) { totalPoints = dpc.points; break; } } } var spent = 0; for(int i = 0; i < allocatedPoints.Length; i++) { spent += allocatedPoints[i]; } remainingPoints = totalPoints - spent; } // --- Core calculation --- private void RecalculateAll() { // 1. Start from default attributes + player allocations var baseAttrs = starterCharacterSettings.defaultEntityAttributes; var attrTypes = new[] { AttributeType.Might, AttributeType.Reflex, AttributeType.Knowledge, AttributeType.Perception }; var finalAttrs = new Attribute[attrTypes.Length]; for(int i = 0; i < attrTypes.Length; i++) { var baseVal = baseAttrs.GetValue(attrTypes[i]); finalAttrs[i] = new Attribute(attrTypes[i], baseVal + allocatedPoints[i]); } workingAttributes = new EntityAttributes { attributes = finalAttrs }; // 2. Build combined perks and modifiers (defaults + racial + class + player) // Racial/class attribute and stat bonuses flow through permanentModifiers var combinedPerks = BuildCombinedPerks(); var combinedModifiers = BuildCombinedModifiers(); // 3. Build temp entity for modifier collection var tempEntity = new CharacterCreationRequest { Id = Guid.NewGuid(), Race = selectedRace, Class = selectedClass, Role = CharacterRole.Protagonist, Attributes = workingAttributes, Perks = combinedPerks, Modifiers = combinedModifiers }; // 4. Resolve attributes through modifiers (racial/class attribute bonuses come via modifiers) var resolver = characterSystems.ModifierResolver; var resolvedAttrs = new Attribute[attrTypes.Length]; for(int i = 0; i < attrTypes.Length; i++) { var mods = resolver.CollectModifiers(tempEntity, attrTypes[i]); resolvedAttrs[i] = new Attribute(attrTypes[i], resolver.Resolve(finalAttrs[i].value, mods, tempEntity)); } workingAttributes = new EntityAttributes { attributes = resolvedAttrs }; tempEntity.Attributes = workingAttributes; // 5. Calculate stats through modifiers (racial/class stat bonuses come via modifiers) var baseStats = starterCharacterSettings.defaultEntityStats; var statTypes = new[] { StatType.Health, StatType.Mana, StatType.Level, StatType.Experience }; var resolvedStats = new Stat[statTypes.Length]; for(int i = 0; i < statTypes.Length; i++) { var baseVal = baseStats.GetValue(statTypes[i]); var mods = resolver.CollectModifiers(tempEntity, statTypes[i]); resolvedStats[i] = new Stat(statTypes[i], resolver.Resolve(baseVal, mods, tempEntity)); } workingStats = new EntityStats { stats = resolvedStats }; // 9. Update UI UpdateAttributeUI(); UpdateStatUI(); UpdatePointsDisplay(); // 10. Log stat deltas var newHealth = workingStats.GetValue(StatType.Health); var newStamina = workingStats.GetValue(StatType.Mana); if(newHealth != previousHealth && previousHealth != 0) { var delta = newHealth - previousHealth; var sign = delta > 0 ? "+" : ""; inGameLogger.Log($"Health: {previousHealth} -> {newHealth} ({sign}{delta})", "#87CEEB"); } if(newStamina != previousStamina && previousStamina != 0) { var delta = newStamina - previousStamina; var sign = delta > 0 ? "+" : ""; inGameLogger.Log($"Stamina: {previousStamina} -> {newStamina} ({sign}{delta})", "#FFFF99"); } previousHealth = newHealth; previousStamina = newStamina; } private PerksData BuildCombinedPerks() { // Start with defaults, add racial/class/player perks (deduplicate by Id) var combined = new PerksData { perks = new List() }; var seenIds = new HashSet(); void AddPerks(PerksData source) { if(source?.perks == null) { return; } foreach(var perk in source.perks) { if(perk != null && seenIds.Add(perk.Id)) { combined.perks.Add(perk); } } } AddPerks(starterCharacterSettings.defaultPerksData); AddPerks(racialPerks); AddPerks(classPerks); foreach(var perk in playerPerks) { if(perk != null && seenIds.Add(perk.Id)) { combined.perks.Add(perk); } } return combined; } private ModifiersData BuildCombinedModifiers() { // Start with defaults. Racial/class modifiers override defaults that target the same // thing, but ONLY if the override's requirements are currently met. If requirements // are not met, the default stays and the override is still added — the resolver will // skip the unqualified override at resolution time, leaving the default active. var combined = new ModifiersData { modifiers = new List() }; // Seed with defaults if(starterCharacterSettings.defaultModifiersData?.modifiers != null) { combined.modifiers.AddRange(starterCharacterSettings.defaultModifiersData.modifiers); } // Override with racial modifiers OverrideModifiers(combined, racialModifiers, workingAttributes); // Override with class modifiers OverrideModifiers(combined, classModifiers, workingAttributes); return combined; } private static void OverrideModifiers(ModifiersData combined, ModifiersData overrides, EntityAttributes currentAttributes) { if(overrides?.modifiers == null) { return; } foreach(var mod in overrides.modifiers) { if(mod?.Target == null) { continue; } // Only remove the default if the override's requirements are currently met. // Both are added regardless — the resolver skips unqualified modifiers at // resolution time, so if requirements aren't met, the default still applies. var requirementsMet = AreRequirementsMet(mod, currentAttributes); if(requirementsMet) { for(int i = combined.modifiers.Count - 1; i >= 0; i--) { var existing = combined.modifiers[i]; if(existing?.Target != null && TargetsMatch(existing.Target, mod.Target)) { combined.modifiers.RemoveAt(i); } } } combined.modifiers.Add(mod); } } private static bool AreRequirementsMet(ModifierDefinition mod, EntityAttributes attributes) { if(mod.Requirements == null || mod.Requirements.Count == 0) { return true; } foreach(var req in mod.Requirements) { if(!req.IsMet(attributes)) { return false; } } return true; } private static bool TargetsMatch(ModifierTarget a, ModifierTarget b) { if(a.Type != b.Type) { return false; } return a.Type switch { ModifierTargetType.Attribute => a.AttributeType == b.AttributeType, ModifierTargetType.Stat => a.StatType == b.StatType, ModifierTargetType.CombatScore => a.CombatScoreType == b.CombatScoreType, _ => false }; } // --- UI updates --- private void UpdateAttributeUI() { var attrTypes = new[] { AttributeType.Might, AttributeType.Reflex, AttributeType.Knowledge, AttributeType.Perception }; var attrRefs = characterCreationReference.attributeReference; for(int i = 0; i < attrRefs.Length && i < attrTypes.Length; i++) { attrRefs[i].attributeValue.text = workingAttributes.GetValue(attrTypes[i]).ToString(); attrRefs[i].addPointsButton.interactable = remainingPoints > 0; attrRefs[i].removePointsButton.interactable = allocatedPoints[i] > 0; } } private void UpdateStatUI() { var statTypes = new[] { StatType.Health, StatType.Mana }; var statRefs = characterCreationReference.statReference; for(int i = 0; i < statRefs.Length && i < statTypes.Length; i++) { var value = workingStats.GetValue(statTypes[i]); statRefs[i].statName.text = statTypes[i].ToString(); statRefs[i].statValue.text = value.ToString(); statRefs[i].statBar.fillAmount = Mathf.Clamp01(value / 200f); } } private void UpdatePointsDisplay() { characterCreationReference.pointsToDistribute.text = remainingPoints.ToString(); } // --- Event handlers --- private void OnRaceChanged(int index) { selectedRace = (CharacterRace)(index + 1); Array.Clear(allocatedPoints, 0, allocatedPoints.Length); ApplyRacialBonuses(); UpdateRemainingPoints(); RecalculateAll(); inGameLogger.Log($"Race changed to {selectedRace}"); } private void OnClassChanged(int index) { selectedClass = (CharacterClass)(index + 1); Array.Clear(allocatedPoints, 0, allocatedPoints.Length); ApplyClassBonuses(); UpdateRemainingPoints(); RecalculateAll(); inGameLogger.Log($"Class changed to {selectedClass}"); } private void OnPerkSelected(int index) { if(index <= 0 || index > availablePerks.Count) { return; // "Select a perk..." placeholder } var perkIndex = index - 1; // offset for placeholder var perk = availablePerks[perkIndex]; playerPerks.Add(new PerkDefinition { Id = perk.Id, Name = perk.Name, Modifiers = perk.Modifiers }); availablePerks.RemoveAt(perkIndex); PopulatePerksDropdown(); RecalculateAll(); inGameLogger.Log($"Perk added: {perk.Name}"); } private void OnAttributeAdd(AttributeType type) { if(remainingPoints <= 0) { return; } var idx = (int)type - 1; allocatedPoints[idx]++; remainingPoints--; RecalculateAll(); } private void OnAttributeRemove(AttributeType type) { var idx = (int)type - 1; if(allocatedPoints[idx] <= 0) { return; } allocatedPoints[idx]--; remainingPoints++; RecalculateAll(); } private void OnPortraitLeft() { if(portraitsHolder == null || portraitsHolder.portraits.Length == 0) { return; } currentPortraitIndex--; if(currentPortraitIndex < 0) { currentPortraitIndex = portraitsHolder.portraits.Length - 1; } characterCreationReference.portraitImage.sprite = portraitsHolder.portraits[currentPortraitIndex]; } private void OnPortraitRight() { if(portraitsHolder == null || portraitsHolder.portraits.Length == 0) { return; } currentPortraitIndex++; if(currentPortraitIndex >= portraitsHolder.portraits.Length) { currentPortraitIndex = 0; } characterCreationReference.portraitImage.sprite = portraitsHolder.portraits[currentPortraitIndex]; } private void OnBackClicked() { if(confirmBackAction != null) { confirmBackAction(); return; } Hide(); } // --- Accept --- private void OnAcceptClicked() { var errors = new List(); if(selectedRace == CharacterRace.None) { errors.Add("Race must be selected"); } if(selectedClass == CharacterClass.None) { errors.Add("Class must be selected"); } if(remainingPoints > 0) { errors.Add($"{remainingPoints} distribution points remaining"); } var characterName = characterCreationReference.nameInputField.text; if(string.IsNullOrWhiteSpace(characterName)) { errors.Add("Name cannot be empty"); } if(errors.Count > 0) { foreach(var error in errors) { inGameLogger.Log(error, "#FF4444"); } return; } var request = new CharacterCreationRequest { Id = Guid.NewGuid(), Name = characterName, Race = selectedRace, Class = selectedClass, Role = CharacterRole.Protagonist, PortraitIndex = currentPortraitIndex, Attributes = workingAttributes, Stats = workingStats, Perks = BuildCombinedPerks(), Modifiers = BuildCombinedModifiers() }; characterCreationRequests = new List { request }; // Log full breakdown inGameLogger.Log("--- Character Accepted ---"); inGameLogger.Log($"Name: {request.Name}", "#FFBF00"); inGameLogger.Log($"Race: {request.Race}"); inGameLogger.Log($"Class: {request.Class}"); inGameLogger.Log($"Portrait: #{request.PortraitIndex}"); inGameLogger.Log($"{request.Attributes}"); inGameLogger.Log($"{request.Stats}"); if(request.Perks?.perks != null && request.Perks.perks.Count > 0) { foreach(var perk in request.Perks.perks) { inGameLogger.Log($"Perk: {perk.Name}"); } } CreateParty(); canStartCheck.Invoke(); } private void CreateParty() { var partyCreatorModel = new PartyCreatorModel(characterSystems.CharacterFactory, characterSystems.PartyFactory, characterCreationRequests, partySettings); var party = partyCreatorModel.CreatePartyForNewRun(); gameDataState.ActiveParty = party; } // --- Lifecycle --- public void Tick() { } public void Show() { characterCreationReference.gameObject.SetActive(true); } public void Hide() { characterCreationReference.gameObject.SetActive(false); } public void Dispose() { inGameLogger.Disable(); } } }